@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,131 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai";
3
- import { imageFromBase64, MAX_IMAGES_BYTES } from "../core/inference/images.ts";
4
- import { object, requiredString, type ChatMessage, type ChatTool, type ModelInfo } from "../core/types.ts";
5
- import { DEVIN_API } from "./models.ts";
6
-
7
- const REPLAY_PREFIX = "devin:v1:";
8
- const replayStrings = ["signature", "signatureType", "geminiThoughtSignature", "outputId", "thinkingId", "phase", "modelUid"] as const;
9
- type ReplayMetadata = Pick<ChatMessage, typeof replayStrings[number] | "thinkingRedacted">;
10
-
11
- export function encodeReplayMetadata(message: ChatMessage): string {
12
- const metadata: ReplayMetadata = {};
13
- for (const key of replayStrings) if (message[key] !== undefined) metadata[key] = message[key];
14
- if (message.thinkingRedacted !== undefined) metadata.thinkingRedacted = message.thinkingRedacted;
15
- return REPLAY_PREFIX + JSON.stringify(metadata);
16
- }
17
- function replayMetadata(value: string | undefined): ReplayMetadata | undefined {
18
- if (!value?.startsWith(REPLAY_PREFIX)) return undefined;
19
- let parsed: Record<string, unknown>;
20
- try { parsed = object(JSON.parse(value.slice(REPLAY_PREFIX.length)), "Devin replay metadata"); }
21
- catch { throw new Error("Invalid Devin response replay metadata."); }
22
- const result: ReplayMetadata = {};
23
- for (const key of replayStrings) {
24
- if (parsed[key] !== undefined && typeof parsed[key] !== "string") throw new Error("Unexpected Devin response replay metadata format.");
25
- if (typeof parsed[key] === "string") result[key] = parsed[key];
26
- }
27
- if (parsed.thinkingRedacted !== undefined) {
28
- if (typeof parsed.thinkingRedacted !== "boolean") throw new Error("Invalid Devin redacted thinking metadata.");
29
- result.thinkingRedacted = parsed.thinkingRedacted;
30
- }
31
- return result;
32
- }
33
-
34
- export function attachReplayMetadata(output: AssistantMessage, message: ChatMessage): void {
35
- const signature = encodeReplayMetadata(message);
36
- for (const block of output.content) {
37
- if (block.type === "thinking") block.thinkingSignature = signature;
38
- else if (block.type === "text") block.textSignature = signature;
39
- else block.thoughtSignature = signature;
40
- }
41
- }
42
-
43
- const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
44
- const SCHEMA_DATA = new Set(["enum", "const", "default", "examples", "example"]);
45
-
46
- /** Devin's own tools give every schema a single `type`; its Gemini route rejects a list such as `["integer", "null"]`. */
47
- function toDevinToolSchema(schema: unknown): unknown {
48
- if (Array.isArray(schema)) return schema.map(toDevinToolSchema);
49
- if (!schema || typeof schema !== "object") return schema;
50
- const result: Record<string, unknown> = {};
51
- for (const [key, value] of Object.entries(schema)) {
52
- if (SCHEMA_DATA.has(key)) result[key] = value;
53
- else if (SCHEMA_MAPS.has(key) && value && typeof value === "object" && !Array.isArray(value)) {
54
- result[key] = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, toDevinToolSchema(child)]));
55
- } else if (key === "type" && Array.isArray(value)) {
56
- // Omitting the field still covers null; several remaining types become one branch each.
57
- const types = value.filter((type) => type !== "null");
58
- if (types.length === 1) result.type = types[0];
59
- else if (types.length > 1 && !("anyOf" in schema)) result.anyOf = types.map((type) => ({ type }));
60
- else result.type = value;
61
- } else result[key] = toDevinToolSchema(value);
62
- }
63
- return result;
64
- }
65
-
66
- export function toDevinTranscript(context: TranscriptContext, model: ModelInfo): { messages: ChatMessage[]; tools: ChatTool[]; systemPrompt: string } {
67
- const declarations = getCurrentTools(context.messages);
68
- if (declarations.length && !model.supportsToolCalls) throw new Error("This model does not support the current Pi tool declarations.");
69
- const tools = declarations.map((tool) => {
70
- // Grammar sampling is optional; like Pi's providers without grammar tools, send the JSON schema instead.
71
- if (tool.constrainedSampling && tool.constrainedSampling.type === "json_schema" && tool.constrainedSampling.strict === "require") {
72
- throw new Error(`Devin does not support this tool's forced sampling format: ${tool.name}`);
73
- }
74
- return { name: tool.name, description: tool.description, jsonSchemaString: JSON.stringify(toDevinToolSchema(tool.parameters)) };
75
- });
76
- const failedCalls = new Set(context.messages.flatMap((message) => message.role === "assistant" && ["error", "aborted"].includes(message.stopReason)
77
- ? message.content.filter((block) => block.type === "toolCall").map((block) => block.id) : []));
78
- const messages: ChatMessage[] = [];
79
- for (const message of context.messages) {
80
- if (message.role === "system") continue;
81
- if (message.role === "assistant") {
82
- if (message.stopReason === "error" || message.stopReason === "aborted") continue;
83
- if (message.stopReason === "pending") throw new Error("An unfinished assistant response cannot be sent as Devin history.");
84
- const own = message.provider === "devin" && message.api === DEVIN_API;
85
- const result: ChatMessage = { messageId: message.responseId ?? randomUUID(), source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
86
- let replay: ReplayMetadata | undefined;
87
- for (const block of message.content) {
88
- if (block.type === "text") result.prompt += block.text;
89
- else if (block.type === "thinking") {
90
- if (!block.redacted) result.thinking = (result.thinking ?? "") + block.thinking;
91
- else result.thinkingRedacted = true;
92
- } else {
93
- result.toolCalls ??= [];
94
- result.toolCalls.push({ id: requiredString(block.id, "tool call id"), name: block.namespace ? `${block.namespace}.${block.name}` : block.name,
95
- argumentsJson: JSON.stringify(object(block.arguments, "tool arguments")) });
96
- }
97
- if (own) {
98
- const metadata = replayMetadata(block.type === "text" ? block.textSignature : block.type === "thinking" ? block.thinkingSignature : block.thoughtSignature);
99
- if (metadata) {
100
- if (replay && JSON.stringify(replay) !== JSON.stringify(metadata)) throw new Error("Devin replay metadata differs within one response.");
101
- replay = metadata;
102
- }
103
- }
104
- }
105
- Object.assign(result, replay);
106
- if (own && message.providerThinkingLevel) result.modelUid = message.providerThinkingLevel;
107
- if (result.thinkingRedacted) result.thinking = "";
108
- messages.push(result);
109
- continue;
110
- }
111
- if (message.role === "toolResult" && failedCalls.has(message.toolCallId)) continue;
112
- const result: ChatMessage = {
113
- messageId: randomUUID(), source: message.role === "toolResult" ? "CHAT_MESSAGE_SOURCE_TOOL" : "CHAT_MESSAGE_SOURCE_USER", prompt: "",
114
- ...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolResultIsError: message.isError } : {}),
115
- };
116
- let imageBytes = 0;
117
- const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] : message.content;
118
- for (const block of content) {
119
- if (block.type === "text") result.prompt += block.text;
120
- else {
121
- if (!model.supportsImages) throw new Error("This model does not support images in history or tool results.");
122
- const converted = imageFromBase64(block.data, block.mimeType);
123
- imageBytes += converted.sizeBytes;
124
- if (imageBytes > MAX_IMAGES_BYTES) throw new Error(`Images in one message must total at most ${MAX_IMAGES_BYTES} bytes.`);
125
- (result.images ??= []).push(converted.image);
126
- }
127
- }
128
- messages.push(result);
129
- }
130
- return { messages, tools, systemPrompt: getCurrentSystemPrompt(context.messages) };
131
- }
package/src/pi/models.ts DELETED
@@ -1,379 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type { Credential as HostCredential, Model, ModelsStoreEntry, RefreshModelsContext } from "@earendil-works/pi-ai";
3
- import { awaitWithSignal, endpoint } from "../core/protocol/transport.ts";
4
- import { object, type Credential, type JsonObject } from "../core/types.ts";
5
- import type { ModelCatalog } from "../core/models/catalog.ts";
6
- import {
7
- EFFECTIVE_THINKING_LEVELS, modelIDForUID, projectModelVariants, resolveBackendSelection, restoreModelVariants,
8
- type BackendModelSelection, type EffectiveThinkingLevel, type ModelVariant, type VariantProjection,
9
- } from "../core/models/variants.ts";
10
-
11
- export const DEVIN_API = "devin-connect";
12
- const CATALOG_VERSION = 1;
13
- const DEFAULT_FRESHNESS_MS = 5 * 60_000;
14
- /** Default sidekick when nothing is saved or the saved one is gone. The default lead is the group this UID belongs to. */
15
- export const FUSION_SIDEKICK_UID = "swe-2-medium";
16
- /** The single Fusion entry shown in `/model`. Lead and sidekick follow the global `/devin-fusion` choice. */
17
- export const FUSION_MODEL_ID = "fusion";
18
- /** The lead is a published group model ID; the sidekick is an exact variant model ID. Both come from `publicModelIDs`. */
19
- export interface FusionSelection { leadID: string; sidekickID: string }
20
- export interface FusionChoices {
21
- leads: { id: string; name: string; recommended?: true }[];
22
- /** `level` is the thinking level of this UID. It is passed to the sidekick child Pi as is. */
23
- sidekicks: { id: string; name: string; groupName: string; level: EffectiveThinkingLevel; recommended?: true }[];
24
- }
25
- export interface DevinModel extends Model<typeof DEVIN_API> {
26
- devin: {
27
- version: number; scope: string; costsUnknown: true; configs: JsonObject[]; unavailable?: JsonObject[];
28
- fusion?: FusionSelection;
29
- /** Marked as recommended by the catalog. For a group, true when any variant is recommended. */
30
- recommended?: true;
31
- };
32
- }
33
-
34
- /** Options for projecting the model list. Defaults: group list, Fusion included, recommended filter off. */
35
- export interface ProjectionOptions {
36
- /** Saved Fusion choice. false (sidekick child Pi) creates no Fusion entry. */
37
- fusion?: FusionSelection | false;
38
- /** Lists only models with a variant marked as recommended. Groups stay groups. The Fusion entry is unaffected. */
39
- recommendedOnly?: boolean;
40
- /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
41
- listsAllExactModels?: boolean;
42
- }
43
-
44
- export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
45
-
46
- /**
47
- * A model whose catalog entry marks 24-hour prompt cache retention as a variant attribute.
48
- * The request wire has no retention field, so this flag is the only basis for allowing long retention.
49
- */
50
- export function hasExtendedPromptCacheRetention(variant: ModelVariant): boolean {
51
- return variant.dimensions.some((dimension) => dimension.key === "Prompt Cache Retention" && dimension.name === "24h");
52
- }
53
-
54
- export function catalogScope(credential: Credential): string {
55
- return createHash("sha256").update(endpoint(credential.apiServerURL) + "\0" + credential.sessionToken).digest("hex");
56
- }
57
-
58
- function thinkingMap(mappings: Partial<Record<EffectiveThinkingLevel, string>>) {
59
- return Object.fromEntries(EFFECTIVE_THINKING_LEVELS.map((level) => [level, mappings[level] ?? null]));
60
- }
61
-
62
- function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential): DevinModel {
63
- const first = variants[0].model;
64
- return {
65
- id, name, api: DEVIN_API, provider: "devin", baseUrl: credential.apiServerURL,
66
- reasoning: Object.keys(mappings).some((level) => level !== "off"), thinkingLevelMap: thinkingMap(mappings),
67
- input: first.supportsImages ? ["text", "image"] : ["text"],
68
- contextWindow: first.contextWindow, maxTokens: first.maxOutputTokens,
69
- // Placeholder for Pi's required numeric fields. It does not mean free or a real USD price.
70
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
71
- devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config) },
72
- };
73
- }
74
-
75
- type VariantGroup = VariantProjection["groups"][number];
76
- const byModelID = (a: ModelVariant, b: ModelVariant) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0;
77
- const isUsable = (variant: ModelVariant) => variant.level !== undefined && !variant.unsupported;
78
- const publishedVariants = (group: VariantGroup) => group.variants.filter(isUsable).sort(byModelID);
79
-
80
- /**
81
- * Dimension labels that tell groups apart: only dimensions whose values differ among published groups of the same family name.
82
- * When the catalog value name is empty, 0 counts as the default and is omitted; an enabled value uses the dimension name.
83
- */
84
- function groupDistinctParts(projection: VariantProjection): Map<string, string[]> {
85
- const families = new Map<string, VariantGroup[]>();
86
- for (const group of projection.groups) families.set(group.name, [...(families.get(group.name) ?? []), group]);
87
- const result = new Map<string, string[]>();
88
- for (const groups of families.values()) {
89
- const peers = groups.filter((group) => group.canPublish).length > 1 ? groups.filter((group) => group.canPublish) : groups;
90
- const values = (key: string) => new Set(peers.map((group) => {
91
- const dimension = group.dimensions.find((entry) => entry.key === key);
92
- return dimension ? `${dimension.order}\0${dimension.name}` : "";
93
- }));
94
- for (const group of groups) {
95
- const parts = group.dimensions.filter((dimension) => values(dimension.key).size > 1).flatMap((dimension) => {
96
- if (dimension.name) return [`${dimension.key} ${dimension.name}`];
97
- if (dimension.order === 0) return [];
98
- const orders = new Set(peers.map((peer) => peer.dimensions.find((entry) => entry.key === dimension.key)).filter((entry) => entry && !entry.name && entry.order !== 0).map((entry) => entry!.order));
99
- return [orders.size > 1 ? `${dimension.key} ${dimension.order}` : dimension.key];
100
- });
101
- result.set(group.id, parts);
102
- }
103
- }
104
- return result;
105
- }
106
-
107
- /** Human-readable group name. Example: `Claude Opus 4.8 · Fast Mode`. */
108
- function groupDisplayNames(projection: VariantProjection): Map<string, string> {
109
- const parts = groupDistinctParts(projection);
110
- return new Map(projection.groups.map((group) => [group.id, [group.name, ...parts.get(group.id)!].join(" · ")]));
111
- }
112
-
113
- const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/^-+|-+$/g, "");
114
-
115
- /** Model IDs shown in Pi lists. Groups are keyed by their internal `family:` key, variants by catalog UID. */
116
- export interface PublicModelIDs {
117
- groups: ReadonlyMap<string, string>;
118
- variants: ReadonlyMap<string, string>;
119
- targets: ReadonlyMap<string, { group: VariantGroup } | { variant: ModelVariant }>;
120
- }
121
-
122
- const publicIDCache = new WeakMap<VariantProjection, PublicModelIDs>();
123
- /**
124
- * Pi `/model` shows IDs, not model names, so the IDs are made human-readable.
125
- * Published groups use `familyID[-differing dimensions]` (e.g. `claude-opus-4.8-fast-mode`); variants use the catalog UID (e.g. `swe-2-high`).
126
- * The same catalog always yields the same IDs regardless of display mode. On a clash, groups win and the variant gets `-exact`.
127
- */
128
- export function publicModelIDs(projection: VariantProjection): PublicModelIDs {
129
- const cached = publicIDCache.get(projection);
130
- if (cached) return cached;
131
- const parts = groupDistinctParts(projection), taken = new Set([FUSION_MODEL_ID]);
132
- const groups = new Map<string, string>(), variants = new Map<string, string>(), targets = new Map<string, { group: VariantGroup } | { variant: ModelVariant }>();
133
- const claim = (base: string) => {
134
- let id = base;
135
- for (let suffix = 2; taken.has(id); suffix++) id = `${base}-${suffix}`;
136
- taken.add(id);
137
- return id;
138
- };
139
- for (const group of projection.groups.filter((entry) => entry.canPublish).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
140
- const id = claim([slug(group.family), ...parts.get(group.id)!.map(slug)].filter(Boolean).join("-") || "model");
141
- groups.set(group.id, id); targets.set(id, { group });
142
- }
143
- for (const variant of projection.variants.filter(isUsable).sort(byModelID)) {
144
- const id = claim(taken.has(variant.model.id) ? `${variant.model.id}-exact` : variant.model.id);
145
- variants.set(variant.model.id, id); targets.set(id, { variant });
146
- }
147
- const result = { groups, variants, targets };
148
- publicIDCache.set(projection, result);
149
- return result;
150
- }
151
-
152
- /**
153
- * Fusion candidates. The lead must call the sidekick tool and the sidekick works with Pi tools, so
154
- * only models that support tool calls qualify. All variants of a published group share capabilities.
155
- */
156
- export function fusionChoices(projection: VariantProjection): FusionChoices {
157
- const names = groupDisplayNames(projection), ids = publicModelIDs(projection);
158
- const leads = projection.groups.filter((group) => group.canPublish && publishedVariants(group)[0]?.model.supportsToolCalls)
159
- .map((group) => ({ id: ids.groups.get(group.id)!, name: names.get(group.id)!,
160
- ...(group.variants.some((variant) => variant.model.isRecommended) ? { recommended: true as const } : {}) }));
161
- const groupOf = new Map<string, VariantGroup>();
162
- for (const group of projection.groups) for (const variant of group.variants) groupOf.set(variant.model.id, group);
163
- const sidekicks = projection.variants.filter((variant) => isUsable(variant) && variant.model.supportsToolCalls).sort(byModelID)
164
- .map((variant) => {
165
- const group = groupOf.get(variant.model.id);
166
- return { id: ids.variants.get(variant.model.id)!, name: variant.model.name, groupName: group ? names.get(group.id)! : variant.model.name, level: variant.level!,
167
- ...(variant.model.isRecommended ? { recommended: true as const } : {}) };
168
- });
169
- return { leads, sidekicks };
170
- }
171
-
172
- /** Saved values missing from the current list fall back to the defaults per item. Without defaults, no Fusion entry is built. */
173
- export function effectiveFusion(projection: VariantProjection, requested: FusionSelection | undefined): FusionSelection | undefined {
174
- const { leads, sidekicks } = fusionChoices(projection), ids = publicModelIDs(projection);
175
- const defaultSidekick = ids.variants.get(FUSION_SIDEKICK_UID);
176
- const defaultGroup = projection.groups.find((group) => group.variants.some((variant) => variant.model.id === FUSION_SIDEKICK_UID));
177
- const defaultLead = defaultGroup && ids.groups.get(defaultGroup.id);
178
- // A choice saved in the legacy format (`family:…`, `uid:…`) is read as the same model's new ID.
179
- const current = (id: string | undefined) => id?.startsWith("family:") ? ids.groups.get(id)
180
- : id?.startsWith("uid:") ? ids.variants.get(decodeURIComponent(id.slice("uid:".length))) : id;
181
- const pick = (options: readonly { id: string }[], wanted: string | undefined, fallback: string | undefined) =>
182
- options.find((option) => option.id === current(wanted))?.id ?? options.find((option) => option.id === fallback)?.id;
183
- const leadID = pick(leads, requested?.leadID, defaultLead), sidekickID = pick(sidekicks, requested?.sidekickID, defaultSidekick);
184
- return leadID && sidekickID ? { leadID, sidekickID } : undefined;
185
- }
186
-
187
- /**
188
- * Exact UIDs covered by a published group are hidden and chosen with `/thinking`. Their catalog configs are stored on the group model.
189
- * `recommendedOnly` keeps only groups and uncovered exact UIDs that hold a recommended variant, so `/thinking` still picks the UID.
190
- * Without any recommended variant, it falls back to the default list.
191
- * `listsAllExactModels` is only for processes that pick exact UIDs directly, such as the sidekick child.
192
- */
193
- export function modelsFromProjection(projection: VariantProjection, credential: Credential, options: ProjectionOptions = {}): DevinModel[] {
194
- const models: DevinModel[] = [], names = groupDisplayNames(projection), ids = publicModelIDs(projection);
195
- const published = projection.groups.filter((group) => group.canPublish);
196
- const recommendedUIDs = new Set(projection.variants.filter((variant) => isUsable(variant) && variant.model.isRecommended).map((variant) => variant.model.id));
197
- // Fall back to the default projection so accounts without recommended flags never get an empty list.
198
- const isRecommendedOnly = options.recommendedOnly === true && recommendedUIDs.size > 0;
199
- const isListed = (uids: readonly string[]) => !isRecommendedOnly || uids.some((uid) => recommendedUIDs.has(uid));
200
- for (const group of published) {
201
- if (!isListed(Object.values(group.mappings))) continue;
202
- const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential);
203
- if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
204
- models.push(model);
205
- }
206
- const covered = new Set(published.flatMap((group) => Object.values(group.mappings)));
207
- const exact = projection.variants.filter((variant) => isUsable(variant) && isListed([variant.model.id])
208
- && (options.listsAllExactModels === true || !covered.has(variant.model.id)));
209
- const listedNames = new Set(models.map((model) => model.name));
210
- for (const variant of exact) {
211
- const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
212
- const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential);
213
- if (variant.model.isRecommended) model.devin.recommended = true;
214
- models.push(model);
215
- }
216
- const fusion = options.fusion;
217
- const pair = fusion === false ? undefined : effectiveFusion(projection, fusion);
218
- if (pair) {
219
- const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
220
- const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
221
- const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential);
222
- model.devin.fusion = pair;
223
- models.push(model);
224
- }
225
- models.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
226
- if (models.length) models[0].devin.unavailable = projection.variants.filter((variant) => variant.unsupported)
227
- .sort((a, b) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0).map((variant) => variant.config);
228
- return models;
229
- }
230
-
231
- function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential): VariantProjection | undefined {
232
- if (!stored?.models.length) return undefined;
233
- const scope = catalogScope(credential), configs = new Map<string, JsonObject>();
234
- for (const model of stored.models) {
235
- const metadata = object((model as DevinModel).devin, "cached Devin metadata");
236
- if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
237
- || metadata.version !== CATALOG_VERSION || metadata.scope !== scope) return undefined;
238
- if (!Array.isArray(metadata.configs)) return undefined;
239
- const entries = [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])];
240
- for (const value of entries) {
241
- const config = object(value, "cached model config");
242
- if (typeof config.modelUid !== "string") return undefined;
243
- const previous = configs.get(config.modelUid);
244
- if (previous && JSON.stringify(previous) !== JSON.stringify(config)) return undefined;
245
- configs.set(config.modelUid, config);
246
- }
247
- }
248
- return restoreModelVariants([...configs.values()]);
249
- }
250
-
251
- export interface DevinModelSource {
252
- getModels(): readonly DevinModel[];
253
- filterModels(models: readonly Model<typeof DEVIN_API>[], credential: HostCredential | undefined): readonly Model<typeof DEVIN_API>[];
254
- refreshModels(context: RefreshModelsContext): Promise<void>;
255
- resolve(modelID: string, level: EffectiveThinkingLevel, credential: Credential): BackendModelSelection;
256
- /** Resolves a catalog UID chosen by a payload hook at that variant's thinking level, including UIDs hidden from the list. */
257
- resolveUID(uid: string, credential: Credential): BackendModelSelection;
258
- getUnsupported(): readonly { uid: string; reason: string }[];
259
- /** Thinking level of a usable exact UID in the current catalog, including hidden ones. */
260
- exactLevel(uid: string): EffectiveThinkingLevel | undefined;
261
- /** Fusion candidates of the current catalog. undefined without a catalog or when the source has Fusion disabled. */
262
- getFusionChoices(): FusionChoices | undefined;
263
- /** Rebuilds the model list from the current catalog without network after a saved choice or setting changes. */
264
- rebuildModels(): void;
265
- invalidate(): void;
266
- }
267
-
268
- export function createDevinModelSource(options: {
269
- credentialFromHost: (credential: HostCredential | undefined) => Credential | undefined;
270
- fetchCatalog: (credential: Credential, signal: AbortSignal) => Promise<ModelCatalog>;
271
- now?: () => number;
272
- freshnessMs?: number;
273
- /** Reads the saved Fusion choice. false creates no Fusion entry. */
274
- fusion?: false | (() => FusionSelection | undefined);
275
- /** Reads the saved recommended filter setting. */
276
- recommendedOnly?: () => boolean;
277
- /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
278
- listsAllExactModels?: boolean;
279
- }): DevinModelSource {
280
- const now = options.now ?? Date.now, freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
281
- if (!Number.isSafeInteger(freshnessMs) || freshnessMs < 0) throw new Error("The model cache check interval must be an integer of 0 or more.");
282
- const readFusion = options.fusion;
283
- // The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
284
- // without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
285
- const build = (next: VariantProjection, credential: Credential, isForStorage = false) => modelsFromProjection(next, credential, {
286
- fusion: readFusion === false ? false : readFusion?.(),
287
- recommendedOnly: !isForStorage && options.recommendedOnly?.() === true,
288
- listsAllExactModels: !isForStorage && options.listsAllExactModels === true,
289
- });
290
- let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined;
291
- let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined;
292
- const clear = () => { models = []; projection = undefined; scope = undefined; checkedAtMs = 0; modelCredential = undefined; };
293
- const currentProjection = (credential: Credential) => {
294
- if (!projection || scope !== catalogScope(credential)) throw new Error("No Devin model list matches the current account and endpoint. Refresh the model list.");
295
- return projection;
296
- };
297
- const effectiveCredential = (credential: HostCredential | undefined, baseUrl?: string) => {
298
- const resolved = options.credentialFromHost(credential);
299
- return resolved ? { ...resolved, apiServerURL: endpoint(baseUrl ?? resolved.apiServerURL) } : undefined;
300
- };
301
- return {
302
- getModels: () => models,
303
- getUnsupported: () => projection?.unsupported ?? [],
304
- exactLevel: (uid) => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level,
305
- getFusionChoices: () => projection && readFusion !== false ? fusionChoices(projection) : undefined,
306
- rebuildModels() {
307
- if (projection && modelCredential) models = build(projection, modelCredential);
308
- },
309
- invalidate: clear,
310
- filterModels(candidates, credential) {
311
- try {
312
- const resolved = effectiveCredential(credential, endpointOverride);
313
- if (!resolved) return [];
314
- const expected = catalogScope(resolved);
315
- return candidates.filter((model) => model.provider === "devin" && model.api === DEVIN_API && model.baseUrl === resolved.apiServerURL && (model as DevinModel).devin?.scope === expected);
316
- } catch { return []; }
317
- },
318
- resolve(modelID, level, credential) {
319
- const current = currentProjection(credential);
320
- let wanted = modelID;
321
- if (isFusionModelID(modelID)) {
322
- // Fusion lead requests use the same UID and thinking level as the selected lead group.
323
- const fusion = models.find((model) => model.id === modelID)?.devin.fusion;
324
- if (!fusion) throw new Error(`Fusion model unavailable: ${modelID}`);
325
- wanted = fusion.leadID;
326
- }
327
- const target = publicModelIDs(current).targets.get(wanted);
328
- if (!target) throw new Error(`Model or thinking level unavailable: ${modelID} (${level})`);
329
- return resolveBackendSelection(current, "group" in target ? target.group.id : modelIDForUID(target.variant.model.id), level);
330
- },
331
- resolveUID(uid, credential) {
332
- const current = currentProjection(credential);
333
- const level = current.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level;
334
- if (!level) throw new Error(`Model UID unavailable: ${uid}`);
335
- return resolveBackendSelection(current, modelIDForUID(uid), level);
336
- },
337
- async refreshModels(context) {
338
- context.signal.throwIfAborted();
339
- const credential = effectiveCredential(context.credential, context.baseUrl);
340
- if (!credential) {
341
- await context.publish({ update: clear });
342
- return;
343
- }
344
- const expectedScope = catalogScope(credential);
345
- let restored: VariantProjection | undefined;
346
- try { restored = restoredProjection(context.stored, credential); }
347
- catch { /* A bad cache is never used as run data. The current good state is kept. */ }
348
- if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
349
- const restoredModels = build(restored, credential);
350
- const checkedAt = context.stored?.checkedAt;
351
- const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
352
- const accepted = await context.publish({ update: () => {
353
- projection = restored; models = restoredModels; scope = expectedScope; modelCredential = credential;
354
- checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl;
355
- } });
356
- if (!accepted) return;
357
- } else if (scope !== expectedScope || endpointOverride !== context.baseUrl) {
358
- const accepted = await context.publish({ update: () => { clear(); endpointOverride = context.baseUrl; } });
359
- if (!accepted) return;
360
- }
361
- if (!context.allowNetwork) return;
362
- const ageMs = now() - checkedAtMs;
363
- if (!context.force && scope === expectedScope && projection && checkedAtMs > 0 && ageMs >= 0 && ageMs < freshnessMs) return;
364
- const catalog = await awaitWithSignal(options.fetchCatalog(credential, context.signal), context.signal);
365
- context.signal.throwIfAborted();
366
- const nextProjection = projectModelVariants(catalog);
367
- const nextModels = build(nextProjection, credential);
368
- const checkedAt = now();
369
- if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) throw new Error("Invalid model cache check time.");
370
- await context.publish({
371
- persist: { models: build(nextProjection, credential, true), checkedAt },
372
- update: () => {
373
- projection = nextProjection; models = nextModels; scope = expectedScope; modelCredential = credential;
374
- checkedAtMs = checkedAt; endpointOverride = context.baseUrl;
375
- },
376
- });
377
- },
378
- };
379
- }