@buyong/pi-devin 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
package/src/pi/models.ts
DELETED
|
@@ -1,415 +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
|
-
/** Account identity hash (endpoint and user ID) the catalog belongs to. Lets a new session of the same account reuse it. */
|
|
32
|
-
account?: string;
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Options for projecting the model list. Defaults: group list, Fusion included, recommended filter off. */
|
|
37
|
-
export interface ProjectionOptions {
|
|
38
|
-
/** Saved Fusion choice. false (sidekick child Pi) creates no Fusion entry. */
|
|
39
|
-
fusion?: FusionSelection | false;
|
|
40
|
-
/** Lists only models with a variant marked as recommended. Groups stay groups. The Fusion entry is unaffected. */
|
|
41
|
-
recommendedOnly?: boolean;
|
|
42
|
-
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
43
|
-
listsAllExactModels?: boolean;
|
|
44
|
-
/** Account identity hash recorded with every model. */
|
|
45
|
-
account?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A model whose catalog entry marks 24-hour prompt cache retention as a variant attribute.
|
|
52
|
-
* The request wire has no retention field, so this flag is the only basis for allowing long retention.
|
|
53
|
-
*/
|
|
54
|
-
export function hasExtendedPromptCacheRetention(variant: ModelVariant): boolean {
|
|
55
|
-
return variant.dimensions.some((dimension) => dimension.key === "Prompt Cache Retention" && dimension.name === "24h");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function catalogScope(credential: Credential): string {
|
|
59
|
-
return createHash("sha256").update(endpoint(credential.apiServerURL) + "\0" + credential.sessionToken).digest("hex");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function thinkingMap(mappings: Partial<Record<EffectiveThinkingLevel, string>>) {
|
|
63
|
-
return Object.fromEntries(EFFECTIVE_THINKING_LEVELS.map((level) => [level, mappings[level] ?? null]));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential, account?: string): DevinModel {
|
|
67
|
-
const first = variants[0].model;
|
|
68
|
-
return {
|
|
69
|
-
id, name, api: DEVIN_API, provider: "devin", baseUrl: credential.apiServerURL,
|
|
70
|
-
reasoning: Object.keys(mappings).some((level) => level !== "off"), thinkingLevelMap: thinkingMap(mappings),
|
|
71
|
-
input: first.supportsImages ? ["text", "image"] : ["text"],
|
|
72
|
-
contextWindow: first.contextWindow, maxTokens: first.maxOutputTokens,
|
|
73
|
-
// Placeholder for Pi's required numeric fields. It does not mean free or a real USD price.
|
|
74
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
75
|
-
devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config), ...(account ? { account } : {}) },
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
type VariantGroup = VariantProjection["groups"][number];
|
|
80
|
-
const byModelID = (a: ModelVariant, b: ModelVariant) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0;
|
|
81
|
-
const isUsable = (variant: ModelVariant) => variant.level !== undefined && !variant.unsupported;
|
|
82
|
-
const publishedVariants = (group: VariantGroup) => group.variants.filter(isUsable).sort(byModelID);
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Dimension labels that tell groups apart: only dimensions whose values differ among published groups of the same family name.
|
|
86
|
-
* When the catalog value name is empty, 0 counts as the default and is omitted; an enabled value uses the dimension name.
|
|
87
|
-
*/
|
|
88
|
-
function groupDistinctParts(projection: VariantProjection): Map<string, string[]> {
|
|
89
|
-
const families = new Map<string, VariantGroup[]>();
|
|
90
|
-
for (const group of projection.groups) families.set(group.name, [...(families.get(group.name) ?? []), group]);
|
|
91
|
-
const result = new Map<string, string[]>();
|
|
92
|
-
for (const groups of families.values()) {
|
|
93
|
-
const peers = groups.filter((group) => group.canPublish).length > 1 ? groups.filter((group) => group.canPublish) : groups;
|
|
94
|
-
const values = (key: string) => new Set(peers.map((group) => {
|
|
95
|
-
const dimension = group.dimensions.find((entry) => entry.key === key);
|
|
96
|
-
return dimension ? `${dimension.order}\0${dimension.name}` : "";
|
|
97
|
-
}));
|
|
98
|
-
for (const group of groups) {
|
|
99
|
-
const parts = group.dimensions.filter((dimension) => values(dimension.key).size > 1).flatMap((dimension) => {
|
|
100
|
-
if (dimension.name) return [`${dimension.key} ${dimension.name}`];
|
|
101
|
-
if (dimension.order === 0) return [];
|
|
102
|
-
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));
|
|
103
|
-
return [orders.size > 1 ? `${dimension.key} ${dimension.order}` : dimension.key];
|
|
104
|
-
});
|
|
105
|
-
result.set(group.id, parts);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return result;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Human-readable group name. Example: `Claude Opus 4.8 · Fast Mode`. */
|
|
112
|
-
function groupDisplayNames(projection: VariantProjection): Map<string, string> {
|
|
113
|
-
const parts = groupDistinctParts(projection);
|
|
114
|
-
return new Map(projection.groups.map((group) => [group.id, [group.name, ...parts.get(group.id)!].join(" · ")]));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/^-+|-+$/g, "");
|
|
118
|
-
|
|
119
|
-
/** Model IDs shown in Pi lists. Groups are keyed by their internal `family:` key, variants by catalog UID. */
|
|
120
|
-
export interface PublicModelIDs {
|
|
121
|
-
groups: ReadonlyMap<string, string>;
|
|
122
|
-
variants: ReadonlyMap<string, string>;
|
|
123
|
-
targets: ReadonlyMap<string, { group: VariantGroup } | { variant: ModelVariant }>;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const publicIDCache = new WeakMap<VariantProjection, PublicModelIDs>();
|
|
127
|
-
/**
|
|
128
|
-
* Pi `/model` shows IDs, not model names, so the IDs are made human-readable.
|
|
129
|
-
* Published groups use `familyID[-differing dimensions]` (e.g. `claude-opus-4.8-fast-mode`); variants use the catalog UID (e.g. `swe-2-high`).
|
|
130
|
-
* The same catalog always yields the same IDs regardless of display mode. On a clash, groups win and the variant gets `-exact`.
|
|
131
|
-
*/
|
|
132
|
-
export function publicModelIDs(projection: VariantProjection): PublicModelIDs {
|
|
133
|
-
const cached = publicIDCache.get(projection);
|
|
134
|
-
if (cached) return cached;
|
|
135
|
-
const parts = groupDistinctParts(projection), taken = new Set([FUSION_MODEL_ID]);
|
|
136
|
-
const groups = new Map<string, string>(), variants = new Map<string, string>(), targets = new Map<string, { group: VariantGroup } | { variant: ModelVariant }>();
|
|
137
|
-
const claim = (base: string) => {
|
|
138
|
-
let id = base;
|
|
139
|
-
for (let suffix = 2; taken.has(id); suffix++) id = `${base}-${suffix}`;
|
|
140
|
-
taken.add(id);
|
|
141
|
-
return id;
|
|
142
|
-
};
|
|
143
|
-
for (const group of projection.groups.filter((entry) => entry.canPublish).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
144
|
-
const id = claim([slug(group.family), ...parts.get(group.id)!.map(slug)].filter(Boolean).join("-") || "model");
|
|
145
|
-
groups.set(group.id, id); targets.set(id, { group });
|
|
146
|
-
}
|
|
147
|
-
for (const variant of projection.variants.filter(isUsable).sort(byModelID)) {
|
|
148
|
-
const id = claim(taken.has(variant.model.id) ? `${variant.model.id}-exact` : variant.model.id);
|
|
149
|
-
variants.set(variant.model.id, id); targets.set(id, { variant });
|
|
150
|
-
}
|
|
151
|
-
const result = { groups, variants, targets };
|
|
152
|
-
publicIDCache.set(projection, result);
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Fusion candidates. The lead must call the sidekick tool and the sidekick works with Pi tools, so
|
|
158
|
-
* only models that support tool calls qualify. All variants of a published group share capabilities.
|
|
159
|
-
*/
|
|
160
|
-
export function fusionChoices(projection: VariantProjection): FusionChoices {
|
|
161
|
-
const names = groupDisplayNames(projection), ids = publicModelIDs(projection);
|
|
162
|
-
const leads = projection.groups.filter((group) => group.canPublish && publishedVariants(group)[0]?.model.supportsToolCalls)
|
|
163
|
-
.map((group) => ({ id: ids.groups.get(group.id)!, name: names.get(group.id)!,
|
|
164
|
-
...(group.variants.some((variant) => variant.model.isRecommended) ? { recommended: true as const } : {}) }));
|
|
165
|
-
const groupOf = new Map<string, VariantGroup>();
|
|
166
|
-
for (const group of projection.groups) for (const variant of group.variants) groupOf.set(variant.model.id, group);
|
|
167
|
-
const sidekicks = projection.variants.filter((variant) => isUsable(variant) && variant.model.supportsToolCalls).sort(byModelID)
|
|
168
|
-
.map((variant) => {
|
|
169
|
-
const group = groupOf.get(variant.model.id);
|
|
170
|
-
return { id: ids.variants.get(variant.model.id)!, name: variant.model.name, groupName: group ? names.get(group.id)! : variant.model.name, level: variant.level!,
|
|
171
|
-
...(variant.model.isRecommended ? { recommended: true as const } : {}) };
|
|
172
|
-
});
|
|
173
|
-
return { leads, sidekicks };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Saved values missing from the current list fall back to the defaults per item. Without defaults, no Fusion entry is built. */
|
|
177
|
-
export function effectiveFusion(projection: VariantProjection, requested: FusionSelection | undefined): FusionSelection | undefined {
|
|
178
|
-
const { leads, sidekicks } = fusionChoices(projection), ids = publicModelIDs(projection);
|
|
179
|
-
const defaultSidekick = ids.variants.get(FUSION_SIDEKICK_UID);
|
|
180
|
-
const defaultGroup = projection.groups.find((group) => group.variants.some((variant) => variant.model.id === FUSION_SIDEKICK_UID));
|
|
181
|
-
const defaultLead = defaultGroup && ids.groups.get(defaultGroup.id);
|
|
182
|
-
// A choice saved in the legacy format (`family:…`, `uid:…`) is read as the same model's new ID.
|
|
183
|
-
const current = (id: string | undefined) => id?.startsWith("family:") ? ids.groups.get(id)
|
|
184
|
-
: id?.startsWith("uid:") ? ids.variants.get(decodeURIComponent(id.slice("uid:".length))) : id;
|
|
185
|
-
const pick = (options: readonly { id: string }[], wanted: string | undefined, fallback: string | undefined) =>
|
|
186
|
-
options.find((option) => option.id === current(wanted))?.id ?? options.find((option) => option.id === fallback)?.id;
|
|
187
|
-
const leadID = pick(leads, requested?.leadID, defaultLead), sidekickID = pick(sidekicks, requested?.sidekickID, defaultSidekick);
|
|
188
|
-
return leadID && sidekickID ? { leadID, sidekickID } : undefined;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Exact UIDs covered by a published group are hidden and chosen with `/thinking`. Their catalog configs are stored on the group model.
|
|
193
|
-
* `recommendedOnly` keeps only groups and uncovered exact UIDs that hold a recommended variant, so `/thinking` still picks the UID.
|
|
194
|
-
* Without any recommended variant, it falls back to the default list.
|
|
195
|
-
* `listsAllExactModels` is only for processes that pick exact UIDs directly, such as the sidekick child.
|
|
196
|
-
*/
|
|
197
|
-
export function modelsFromProjection(projection: VariantProjection, credential: Credential, options: ProjectionOptions = {}): DevinModel[] {
|
|
198
|
-
const models: DevinModel[] = [], names = groupDisplayNames(projection), ids = publicModelIDs(projection);
|
|
199
|
-
const published = projection.groups.filter((group) => group.canPublish);
|
|
200
|
-
const recommendedUIDs = new Set(projection.variants.filter((variant) => isUsable(variant) && variant.model.isRecommended).map((variant) => variant.model.id));
|
|
201
|
-
// Fall back to the default projection so accounts without recommended flags never get an empty list.
|
|
202
|
-
const isRecommendedOnly = options.recommendedOnly === true && recommendedUIDs.size > 0;
|
|
203
|
-
const isListed = (uids: readonly string[]) => !isRecommendedOnly || uids.some((uid) => recommendedUIDs.has(uid));
|
|
204
|
-
for (const group of published) {
|
|
205
|
-
if (!isListed(Object.values(group.mappings))) continue;
|
|
206
|
-
const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential, options.account);
|
|
207
|
-
if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
|
|
208
|
-
models.push(model);
|
|
209
|
-
}
|
|
210
|
-
const covered = new Set(published.flatMap((group) => Object.values(group.mappings)));
|
|
211
|
-
const exact = projection.variants.filter((variant) => isUsable(variant) && isListed([variant.model.id])
|
|
212
|
-
&& (options.listsAllExactModels === true || !covered.has(variant.model.id)));
|
|
213
|
-
const listedNames = new Set(models.map((model) => model.name));
|
|
214
|
-
for (const variant of exact) {
|
|
215
|
-
const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
|
|
216
|
-
const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential, options.account);
|
|
217
|
-
if (variant.model.isRecommended) model.devin.recommended = true;
|
|
218
|
-
models.push(model);
|
|
219
|
-
}
|
|
220
|
-
const fusion = options.fusion;
|
|
221
|
-
const pair = fusion === false ? undefined : effectiveFusion(projection, fusion);
|
|
222
|
-
if (pair) {
|
|
223
|
-
const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
|
|
224
|
-
const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
|
|
225
|
-
const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential, options.account);
|
|
226
|
-
model.devin.fusion = pair;
|
|
227
|
-
models.push(model);
|
|
228
|
-
}
|
|
229
|
-
models.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
230
|
-
if (models.length) models[0].devin.unavailable = projection.variants.filter((variant) => variant.unsupported)
|
|
231
|
-
.sort((a, b) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0).map((variant) => variant.config);
|
|
232
|
-
return models;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const ACCOUNT_HASH = /^[a-f0-9]{64}$/;
|
|
236
|
-
interface RestoredCatalog { projection: VariantProjection; account?: string; isSameSession: boolean }
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Restores the stored catalog of the same session, or of an earlier session of an account this process verified for `account`.
|
|
240
|
-
* Every entry must share one scope and one account, so a mixed or edited cache is never partly applied.
|
|
241
|
-
*/
|
|
242
|
-
function restoredCatalog(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential, account: string | undefined): RestoredCatalog | undefined {
|
|
243
|
-
if (!stored?.models.length) return undefined;
|
|
244
|
-
const scope = catalogScope(credential), configs = new Map<string, JsonObject>();
|
|
245
|
-
let storedScope: unknown, storedAccount: unknown;
|
|
246
|
-
for (const [index, model] of stored.models.entries()) {
|
|
247
|
-
const metadata = object((model as DevinModel).devin, "cached Devin metadata");
|
|
248
|
-
if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
|
|
249
|
-
|| metadata.version !== CATALOG_VERSION || typeof metadata.scope !== "string") return undefined;
|
|
250
|
-
if (metadata.account !== undefined && (typeof metadata.account !== "string" || !ACCOUNT_HASH.test(metadata.account))) return undefined;
|
|
251
|
-
if (index === 0) { storedScope = metadata.scope; storedAccount = metadata.account; }
|
|
252
|
-
else if (metadata.scope !== storedScope || metadata.account !== storedAccount) return undefined;
|
|
253
|
-
if (!Array.isArray(metadata.configs)) return undefined;
|
|
254
|
-
const entries = [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])];
|
|
255
|
-
for (const value of entries) {
|
|
256
|
-
const config = object(value, "cached model config");
|
|
257
|
-
if (typeof config.modelUid !== "string") return undefined;
|
|
258
|
-
const previous = configs.get(config.modelUid);
|
|
259
|
-
if (previous && JSON.stringify(previous) !== JSON.stringify(config)) return undefined;
|
|
260
|
-
configs.set(config.modelUid, config);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
const isSameSession = storedScope === scope;
|
|
264
|
-
// Another session's catalog is used only for the same verified account; account changes and logout never expose it.
|
|
265
|
-
if (!isSameSession && (account === undefined || storedAccount !== account)) return undefined;
|
|
266
|
-
return { projection: restoreModelVariants([...configs.values()]), account: account ?? storedAccount as string | undefined, isSameSession };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export interface DevinModelSource {
|
|
270
|
-
getModels(): readonly DevinModel[];
|
|
271
|
-
filterModels(models: readonly Model<typeof DEVIN_API>[], credential: HostCredential | undefined): readonly Model<typeof DEVIN_API>[];
|
|
272
|
-
refreshModels(context: RefreshModelsContext): Promise<void>;
|
|
273
|
-
resolve(modelID: string, level: EffectiveThinkingLevel, credential: Credential): BackendModelSelection;
|
|
274
|
-
/** Resolves a catalog UID chosen by a payload hook at that variant's thinking level, including UIDs hidden from the list. */
|
|
275
|
-
resolveUID(uid: string, credential: Credential): BackendModelSelection;
|
|
276
|
-
getUnsupported(): readonly { uid: string; reason: string }[];
|
|
277
|
-
/** Thinking level of a usable exact UID in the current catalog, including hidden ones. */
|
|
278
|
-
exactLevel(uid: string): EffectiveThinkingLevel | undefined;
|
|
279
|
-
/** Fusion candidates of the current catalog. undefined without a catalog or when the source has Fusion disabled. */
|
|
280
|
-
getFusionChoices(): FusionChoices | undefined;
|
|
281
|
-
/** Rebuilds the model list from the current catalog without network after a saved choice or setting changes. */
|
|
282
|
-
rebuildModels(): void;
|
|
283
|
-
/** Scope of the credential seen by the last model refresh, even before its catalog loads. Undefined after logout or sign-in. */
|
|
284
|
-
currentScope(): string | undefined;
|
|
285
|
-
invalidate(): void;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function createDevinModelSource(options: {
|
|
289
|
-
credentialFromHost: (credential: HostCredential | undefined) => Credential | undefined;
|
|
290
|
-
fetchCatalog: (credential: Credential, signal: AbortSignal) => Promise<ModelCatalog>;
|
|
291
|
-
now?: () => number;
|
|
292
|
-
freshnessMs?: number;
|
|
293
|
-
/** Reads the saved Fusion choice. false creates no Fusion entry. */
|
|
294
|
-
fusion?: false | (() => FusionSelection | undefined);
|
|
295
|
-
/** Reads the saved recommended filter setting. */
|
|
296
|
-
recommendedOnly?: () => boolean;
|
|
297
|
-
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
298
|
-
listsAllExactModels?: boolean;
|
|
299
|
-
/** Account identity hash this process verified for the credential, if any. */
|
|
300
|
-
accountOf?: (credential: Credential) => string | undefined;
|
|
301
|
-
}): DevinModelSource {
|
|
302
|
-
const now = options.now ?? Date.now, freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
303
|
-
if (!Number.isSafeInteger(freshnessMs) || freshnessMs < 0) throw new Error("The model cache check interval must be an integer of 0 or more.");
|
|
304
|
-
const readFusion = options.fusion;
|
|
305
|
-
// The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
|
|
306
|
-
// without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
|
|
307
|
-
const build = (next: VariantProjection, credential: Credential, account: string | undefined, isForStorage = false) => modelsFromProjection(next, credential, {
|
|
308
|
-
fusion: readFusion === false ? false : readFusion?.(),
|
|
309
|
-
recommendedOnly: !isForStorage && options.recommendedOnly?.() === true,
|
|
310
|
-
listsAllExactModels: !isForStorage && options.listsAllExactModels === true,
|
|
311
|
-
account,
|
|
312
|
-
});
|
|
313
|
-
let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined, catalogAccount: string | undefined;
|
|
314
|
-
let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined, credentialScope: string | undefined;
|
|
315
|
-
const clear = () => {
|
|
316
|
-
models = []; projection = undefined; scope = undefined; catalogAccount = undefined; checkedAtMs = 0; modelCredential = undefined; credentialScope = undefined;
|
|
317
|
-
};
|
|
318
|
-
const currentProjection = (credential: Credential) => {
|
|
319
|
-
if (!projection || scope !== catalogScope(credential)) throw new Error("No Devin model list matches the current account and endpoint. Refresh the model list.");
|
|
320
|
-
return projection;
|
|
321
|
-
};
|
|
322
|
-
const effectiveCredential = (credential: HostCredential | undefined, baseUrl?: string) => {
|
|
323
|
-
const resolved = options.credentialFromHost(credential);
|
|
324
|
-
return resolved ? { ...resolved, apiServerURL: endpoint(baseUrl ?? resolved.apiServerURL) } : undefined;
|
|
325
|
-
};
|
|
326
|
-
return {
|
|
327
|
-
getModels: () => models,
|
|
328
|
-
getUnsupported: () => projection?.unsupported ?? [],
|
|
329
|
-
exactLevel: (uid) => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level,
|
|
330
|
-
getFusionChoices: () => projection && readFusion !== false ? fusionChoices(projection) : undefined,
|
|
331
|
-
rebuildModels() {
|
|
332
|
-
if (projection && modelCredential) models = build(projection, modelCredential, catalogAccount);
|
|
333
|
-
},
|
|
334
|
-
currentScope: () => credentialScope,
|
|
335
|
-
invalidate: clear,
|
|
336
|
-
filterModels(candidates, credential) {
|
|
337
|
-
try {
|
|
338
|
-
const resolved = effectiveCredential(credential, endpointOverride);
|
|
339
|
-
if (!resolved) return [];
|
|
340
|
-
const expected = catalogScope(resolved);
|
|
341
|
-
return candidates.filter((model) => model.provider === "devin" && model.api === DEVIN_API && model.baseUrl === resolved.apiServerURL && (model as DevinModel).devin?.scope === expected);
|
|
342
|
-
} catch { return []; }
|
|
343
|
-
},
|
|
344
|
-
resolve(modelID, level, credential) {
|
|
345
|
-
const current = currentProjection(credential);
|
|
346
|
-
let wanted = modelID;
|
|
347
|
-
if (isFusionModelID(modelID)) {
|
|
348
|
-
// Fusion lead requests use the same UID and thinking level as the selected lead group.
|
|
349
|
-
const fusion = models.find((model) => model.id === modelID)?.devin.fusion;
|
|
350
|
-
if (!fusion) throw new Error(`Fusion model unavailable: ${modelID}`);
|
|
351
|
-
wanted = fusion.leadID;
|
|
352
|
-
}
|
|
353
|
-
const target = publicModelIDs(current).targets.get(wanted);
|
|
354
|
-
if (!target) throw new Error(`Model or thinking level unavailable: ${modelID} (${level})`);
|
|
355
|
-
return resolveBackendSelection(current, "group" in target ? target.group.id : modelIDForUID(target.variant.model.id), level);
|
|
356
|
-
},
|
|
357
|
-
resolveUID(uid, credential) {
|
|
358
|
-
const current = currentProjection(credential);
|
|
359
|
-
const level = current.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level;
|
|
360
|
-
if (!level) throw new Error(`Model UID unavailable: ${uid}`);
|
|
361
|
-
return resolveBackendSelection(current, modelIDForUID(uid), level);
|
|
362
|
-
},
|
|
363
|
-
async refreshModels(context) {
|
|
364
|
-
context.signal.throwIfAborted();
|
|
365
|
-
const credential = effectiveCredential(context.credential, context.baseUrl);
|
|
366
|
-
if (!credential) {
|
|
367
|
-
await context.publish({ update: clear });
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
const expectedScope = catalogScope(credential);
|
|
371
|
-
let restored: RestoredCatalog | undefined;
|
|
372
|
-
try { restored = restoredCatalog(context.stored, credential, options.accountOf?.(credential)); }
|
|
373
|
-
catch { /* A bad cache is never used as run data. The current good state is kept. */ }
|
|
374
|
-
if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
|
|
375
|
-
const { projection: restoredProjection, account: restoredAccount, isSameSession } = restored;
|
|
376
|
-
const restoredModels = build(restoredProjection, credential, restoredAccount);
|
|
377
|
-
const checkedAt = context.stored?.checkedAt;
|
|
378
|
-
const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
|
|
379
|
-
const accepted = await context.publish({
|
|
380
|
-
// A catalog reused for a new session of the same account is stored under the new session, so a restart restores it too.
|
|
381
|
-
...(isSameSession ? {} : { persist: { models: build(restoredProjection, credential, restoredAccount, true), checkedAt: restoredAtMs } }),
|
|
382
|
-
update: () => {
|
|
383
|
-
projection = restoredProjection; models = restoredModels; scope = expectedScope; catalogAccount = restoredAccount; modelCredential = credential;
|
|
384
|
-
checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
if (!accepted) return;
|
|
388
|
-
} else if (scope !== expectedScope || endpointOverride !== context.baseUrl || credentialScope !== expectedScope) {
|
|
389
|
-
const accepted = await context.publish({ update: () => {
|
|
390
|
-
if (scope !== expectedScope || endpointOverride !== context.baseUrl) clear();
|
|
391
|
-
endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
392
|
-
} });
|
|
393
|
-
if (!accepted) return;
|
|
394
|
-
}
|
|
395
|
-
if (!context.allowNetwork) return;
|
|
396
|
-
const ageMs = now() - checkedAtMs;
|
|
397
|
-
if (!context.force && scope === expectedScope && projection && checkedAtMs > 0 && ageMs >= 0 && ageMs < freshnessMs) return;
|
|
398
|
-
const catalog = await awaitWithSignal(options.fetchCatalog(credential, context.signal), context.signal);
|
|
399
|
-
context.signal.throwIfAborted();
|
|
400
|
-
const nextProjection = projectModelVariants(catalog);
|
|
401
|
-
// Known after an account check in this process, or carried over from this same session's catalog.
|
|
402
|
-
const nextAccount = options.accountOf?.(credential) ?? (scope === expectedScope ? catalogAccount : undefined);
|
|
403
|
-
const nextModels = build(nextProjection, credential, nextAccount);
|
|
404
|
-
const checkedAt = now();
|
|
405
|
-
if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) throw new Error("Invalid model cache check time.");
|
|
406
|
-
await context.publish({
|
|
407
|
-
persist: { models: build(nextProjection, credential, nextAccount, true), checkedAt },
|
|
408
|
-
update: () => {
|
|
409
|
-
projection = nextProjection; models = nextModels; scope = expectedScope; catalogAccount = nextAccount; modelCredential = credential;
|
|
410
|
-
checkedAtMs = checkedAt; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
411
|
-
},
|
|
412
|
-
});
|
|
413
|
-
},
|
|
414
|
-
};
|
|
415
|
-
}
|