@buyong/pi-devin 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +214 -2
- package/index.ts +16 -0
- package/package.json +62 -3
- package/proto/README.md +78 -0
- package/proto/buf/validate/validate.proto +453 -0
- package/proto/cel/expr/checked.proto +109 -0
- package/proto/cel/expr/eval.proto +44 -0
- package/proto/cel/expr/explain.proto +22 -0
- package/proto/cel/expr/syntax.proto +119 -0
- package/proto/cel/expr/value.proto +47 -0
- package/proto/connectext/grpc/status/v1/status.proto +21 -0
- package/proto/errorspb/errors.proto +58 -0
- package/proto/errorspb/hintdetail.proto +9 -0
- package/proto/errorspb/markers.proto +12 -0
- package/proto/errorspb/tags.proto +14 -0
- package/proto/errorspb/testing.proto +8 -0
- package/proto/exa/analytics_pb/analytics.proto +190 -0
- package/proto/exa/api_server_pb/api_server.proto +2590 -0
- package/proto/exa/auth_pb/auth.proto +21 -0
- package/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +81 -0
- package/proto/exa/browser_preview_pb/browser_preview.proto +26 -0
- package/proto/exa/bug_checker_pb/bug_checker.proto +24 -0
- package/proto/exa/cascade_plugins_pb/cascade_plugins.proto +264 -0
- package/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
- package/proto/exa/chat_pb/chat.proto +452 -0
- package/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
- package/proto/exa/codeium_common_pb/browser_context.proto +31 -0
- package/proto/exa/codeium_common_pb/codeium_common.proto +4183 -0
- package/proto/exa/context_module_pb/context_module.proto +174 -0
- package/proto/exa/cortex_pb/cortex.proto +3288 -0
- package/proto/exa/dev_pb/dev.proto +28 -0
- package/proto/exa/diff_action_pb/diff_action.proto +76 -0
- package/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +106 -0
- package/proto/exa/eval_pb/eval.proto +1318 -0
- package/proto/exa/extension_server_pb/extension_server.proto +555 -0
- package/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
- package/proto/exa/index_pb/index.proto +469 -0
- package/proto/exa/knowledge_base_pb/knowledge_base.proto +146 -0
- package/proto/exa/language_server_pb/language_server.proto +2435 -0
- package/proto/exa/model_management_pb/model_management.proto +184 -0
- package/proto/exa/opensearch_clients_pb/opensearch_clients.proto +495 -0
- package/proto/exa/product_analytics_pb/product_analytics.proto +39 -0
- package/proto/exa/prompt_pb/prompt.proto +97 -0
- package/proto/exa/reactive_component_pb/reactive_component.proto +98 -0
- package/proto/exa/seat_management_pb/seat_management.proto +2910 -0
- package/proto/exa/tokenizer_pb/tokenizer.proto +39 -0
- package/proto/exa/trainer_pb/config.proto +643 -0
- package/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
- package/proto/exa/trust_pb/trust.proto +192 -0
- package/proto/exa/user_analytics_pb/user_analytics.proto +752 -0
- package/proto/google/api/expr/v1alpha1/checked.proto +108 -0
- package/proto/google/api/expr/v1alpha1/eval.proto +38 -0
- package/proto/google/api/expr/v1alpha1/explain.proto +22 -0
- package/proto/google/api/expr/v1alpha1/syntax.proto +126 -0
- package/proto/google/api/expr/v1alpha1/value.proto +47 -0
- package/proto/google/protobuf/any.proto +15 -0
- package/proto/google/protobuf/cpp_features.proto +22 -0
- package/proto/google/protobuf/descriptor.proto +473 -0
- package/proto/google/protobuf/duration.proto +16 -0
- package/proto/google/protobuf/empty.proto +14 -0
- package/proto/google/protobuf/go_features.proto +31 -0
- package/proto/google/protobuf/struct.proto +34 -0
- package/proto/google/protobuf/timestamp.proto +16 -0
- package/proto/google/protobuf/wrappers.proto +47 -0
- package/proto/google/rpc/status.proto +18 -0
- package/proto/grpc/binlog/v1/binarylog.proto +89 -0
- package/proto/io/prometheus/client/metrics.proto +103 -0
- package/src/core/auth/access.ts +6 -0
- package/src/core/auth/index.ts +2 -0
- package/src/core/auth/login.ts +177 -0
- package/src/core/auth/rest-exchange.ts +35 -0
- package/src/core/client.ts +153 -0
- package/src/core/index.ts +12 -0
- package/src/core/inference/chat.ts +229 -0
- package/src/core/inference/images.ts +24 -0
- package/src/core/inference/messages.ts +34 -0
- package/src/core/inference/retry.ts +68 -0
- package/src/core/inference/state.ts +8 -0
- package/src/core/models/access.ts +7 -0
- package/src/core/models/catalog.ts +35 -0
- package/src/core/models/index.ts +4 -0
- package/src/core/models/select.ts +43 -0
- package/src/core/models/variants.ts +163 -0
- package/src/core/protocol/device.ts +73 -0
- package/src/core/protocol/fingerprint.ts +59 -0
- package/src/core/protocol/http.ts +109 -0
- package/src/core/protocol/protobuf.ts +112 -0
- package/src/core/protocol/transport.ts +501 -0
- package/src/core/types.ts +115 -0
- package/src/pi/auth.ts +134 -0
- package/src/pi/devin-command.ts +76 -0
- package/src/pi/devin-panel.ts +77 -0
- package/src/pi/devin-settings.ts +50 -0
- package/src/pi/extension.ts +80 -0
- package/src/pi/fusion-command.ts +83 -0
- package/src/pi/fusion-picker.ts +171 -0
- package/src/pi/fusion-settings.ts +44 -0
- package/src/pi/lifecycle.ts +217 -0
- package/src/pi/messages.ts +107 -0
- package/src/pi/models.ts +378 -0
- package/src/pi/provider.ts +241 -0
- package/src/pi/sidekick-report.ts +42 -0
- package/src/pi/sidekick-runtime.ts +287 -0
- package/src/pi/sidekick.ts +190 -0
- package/src/pi/stream.ts +124 -0
- package/src/pi/usage.ts +153 -0
package/src/pi/models.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
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 variants marked as recommended. 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` lists only recommended variants by exact UID. Without any, it falls back to the group list.
|
|
190
|
+
* `listsAllExactModels` is only for processes that pick exact UIDs directly, such as the sidekick child.
|
|
191
|
+
*/
|
|
192
|
+
export function modelsFromProjection(projection: VariantProjection, credential: Credential, options: ProjectionOptions = {}): DevinModel[] {
|
|
193
|
+
const models: DevinModel[] = [], names = groupDisplayNames(projection), ids = publicModelIDs(projection);
|
|
194
|
+
const published = projection.groups.filter((group) => group.canPublish);
|
|
195
|
+
const recommended = projection.variants.filter((variant) => isUsable(variant) && variant.model.isRecommended);
|
|
196
|
+
// Fall back to the default projection so accounts without recommended flags never get an empty list.
|
|
197
|
+
const isRecommendedOnly = options.recommendedOnly === true && recommended.length > 0;
|
|
198
|
+
if (!isRecommendedOnly) {
|
|
199
|
+
for (const group of published) {
|
|
200
|
+
const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential);
|
|
201
|
+
if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
|
|
202
|
+
models.push(model);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const covered = new Set(published.flatMap((group) => Object.values(group.mappings)));
|
|
206
|
+
const exact = isRecommendedOnly ? recommended
|
|
207
|
+
: projection.variants.filter((variant) => isUsable(variant) && (options.listsAllExactModels === true || !covered.has(variant.model.id)));
|
|
208
|
+
const listedNames = new Set(models.map((model) => model.name));
|
|
209
|
+
for (const variant of exact) {
|
|
210
|
+
const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
|
|
211
|
+
const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential);
|
|
212
|
+
if (variant.model.isRecommended) model.devin.recommended = true;
|
|
213
|
+
models.push(model);
|
|
214
|
+
}
|
|
215
|
+
const fusion = options.fusion;
|
|
216
|
+
const pair = fusion === false ? undefined : effectiveFusion(projection, fusion);
|
|
217
|
+
if (pair) {
|
|
218
|
+
const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
|
|
219
|
+
const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
|
|
220
|
+
const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential);
|
|
221
|
+
model.devin.fusion = pair;
|
|
222
|
+
models.push(model);
|
|
223
|
+
}
|
|
224
|
+
models.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
225
|
+
if (models.length) models[0].devin.unavailable = projection.variants.filter((variant) => variant.unsupported)
|
|
226
|
+
.sort((a, b) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0).map((variant) => variant.config);
|
|
227
|
+
return models;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential): VariantProjection | undefined {
|
|
231
|
+
if (!stored?.models.length) return undefined;
|
|
232
|
+
const scope = catalogScope(credential), configs = new Map<string, JsonObject>();
|
|
233
|
+
for (const model of stored.models) {
|
|
234
|
+
const metadata = object((model as DevinModel).devin, "cached Devin metadata");
|
|
235
|
+
if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
|
|
236
|
+
|| metadata.version !== CATALOG_VERSION || metadata.scope !== scope) return undefined;
|
|
237
|
+
if (!Array.isArray(metadata.configs)) return undefined;
|
|
238
|
+
const entries = [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])];
|
|
239
|
+
for (const value of entries) {
|
|
240
|
+
const config = object(value, "cached model config");
|
|
241
|
+
if (typeof config.modelUid !== "string") return undefined;
|
|
242
|
+
const previous = configs.get(config.modelUid);
|
|
243
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(config)) return undefined;
|
|
244
|
+
configs.set(config.modelUid, config);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return restoreModelVariants([...configs.values()]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface DevinModelSource {
|
|
251
|
+
getModels(): readonly DevinModel[];
|
|
252
|
+
filterModels(models: readonly Model<typeof DEVIN_API>[], credential: HostCredential | undefined): readonly Model<typeof DEVIN_API>[];
|
|
253
|
+
refreshModels(context: RefreshModelsContext): Promise<void>;
|
|
254
|
+
resolve(modelID: string, level: EffectiveThinkingLevel, credential: Credential): BackendModelSelection;
|
|
255
|
+
/** Resolves a catalog UID chosen by a payload hook at that variant's thinking level, including UIDs hidden from the list. */
|
|
256
|
+
resolveUID(uid: string, credential: Credential): BackendModelSelection;
|
|
257
|
+
getUnsupported(): readonly { uid: string; reason: string }[];
|
|
258
|
+
/** Thinking level of a usable exact UID in the current catalog, including hidden ones. */
|
|
259
|
+
exactLevel(uid: string): EffectiveThinkingLevel | undefined;
|
|
260
|
+
/** Fusion candidates of the current catalog. undefined without a catalog or when the source has Fusion disabled. */
|
|
261
|
+
getFusionChoices(): FusionChoices | undefined;
|
|
262
|
+
/** Rebuilds the model list from the current catalog without network after a saved choice or setting changes. */
|
|
263
|
+
rebuildModels(): void;
|
|
264
|
+
invalidate(): void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function createDevinModelSource(options: {
|
|
268
|
+
credentialFromHost: (credential: HostCredential | undefined) => Credential | undefined;
|
|
269
|
+
fetchCatalog: (credential: Credential, signal: AbortSignal) => Promise<ModelCatalog>;
|
|
270
|
+
now?: () => number;
|
|
271
|
+
freshnessMs?: number;
|
|
272
|
+
/** Reads the saved Fusion choice. false creates no Fusion entry. */
|
|
273
|
+
fusion?: false | (() => FusionSelection | undefined);
|
|
274
|
+
/** Reads the saved recommended filter setting. */
|
|
275
|
+
recommendedOnly?: () => boolean;
|
|
276
|
+
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
277
|
+
listsAllExactModels?: boolean;
|
|
278
|
+
}): DevinModelSource {
|
|
279
|
+
const now = options.now ?? Date.now, freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
280
|
+
if (!Number.isSafeInteger(freshnessMs) || freshnessMs < 0) throw new Error("The model cache check interval must be an integer of 0 or more.");
|
|
281
|
+
const readFusion = options.fusion;
|
|
282
|
+
// The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
|
|
283
|
+
// without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
|
|
284
|
+
const build = (next: VariantProjection, credential: Credential, isForStorage = false) => modelsFromProjection(next, credential, {
|
|
285
|
+
fusion: readFusion === false ? false : readFusion?.(),
|
|
286
|
+
recommendedOnly: !isForStorage && options.recommendedOnly?.() === true,
|
|
287
|
+
listsAllExactModels: !isForStorage && options.listsAllExactModels === true,
|
|
288
|
+
});
|
|
289
|
+
let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined;
|
|
290
|
+
let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined;
|
|
291
|
+
const clear = () => { models = []; projection = undefined; scope = undefined; checkedAtMs = 0; modelCredential = undefined; };
|
|
292
|
+
const currentProjection = (credential: Credential) => {
|
|
293
|
+
if (!projection || scope !== catalogScope(credential)) throw new Error("No Devin model list matches the current account and endpoint. Refresh the model list.");
|
|
294
|
+
return projection;
|
|
295
|
+
};
|
|
296
|
+
const effectiveCredential = (credential: HostCredential | undefined, baseUrl?: string) => {
|
|
297
|
+
const resolved = options.credentialFromHost(credential);
|
|
298
|
+
return resolved ? { ...resolved, apiServerURL: endpoint(baseUrl ?? resolved.apiServerURL) } : undefined;
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
getModels: () => models,
|
|
302
|
+
getUnsupported: () => projection?.unsupported ?? [],
|
|
303
|
+
exactLevel: (uid) => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level,
|
|
304
|
+
getFusionChoices: () => projection && readFusion !== false ? fusionChoices(projection) : undefined,
|
|
305
|
+
rebuildModels() {
|
|
306
|
+
if (projection && modelCredential) models = build(projection, modelCredential);
|
|
307
|
+
},
|
|
308
|
+
invalidate: clear,
|
|
309
|
+
filterModels(candidates, credential) {
|
|
310
|
+
try {
|
|
311
|
+
const resolved = effectiveCredential(credential, endpointOverride);
|
|
312
|
+
if (!resolved) return [];
|
|
313
|
+
const expected = catalogScope(resolved);
|
|
314
|
+
return candidates.filter((model) => model.provider === "devin" && model.api === DEVIN_API && model.baseUrl === resolved.apiServerURL && (model as DevinModel).devin?.scope === expected);
|
|
315
|
+
} catch { return []; }
|
|
316
|
+
},
|
|
317
|
+
resolve(modelID, level, credential) {
|
|
318
|
+
const current = currentProjection(credential);
|
|
319
|
+
let wanted = modelID;
|
|
320
|
+
if (isFusionModelID(modelID)) {
|
|
321
|
+
// Fusion lead requests use the same UID and thinking level as the selected lead group.
|
|
322
|
+
const fusion = models.find((model) => model.id === modelID)?.devin.fusion;
|
|
323
|
+
if (!fusion) throw new Error(`Fusion model unavailable: ${modelID}`);
|
|
324
|
+
wanted = fusion.leadID;
|
|
325
|
+
}
|
|
326
|
+
const target = publicModelIDs(current).targets.get(wanted);
|
|
327
|
+
if (!target) throw new Error(`Model or thinking level unavailable: ${modelID} (${level})`);
|
|
328
|
+
return resolveBackendSelection(current, "group" in target ? target.group.id : modelIDForUID(target.variant.model.id), level);
|
|
329
|
+
},
|
|
330
|
+
resolveUID(uid, credential) {
|
|
331
|
+
const current = currentProjection(credential);
|
|
332
|
+
const level = current.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level;
|
|
333
|
+
if (!level) throw new Error(`Model UID unavailable: ${uid}`);
|
|
334
|
+
return resolveBackendSelection(current, modelIDForUID(uid), level);
|
|
335
|
+
},
|
|
336
|
+
async refreshModels(context) {
|
|
337
|
+
context.signal.throwIfAborted();
|
|
338
|
+
const credential = effectiveCredential(context.credential, context.baseUrl);
|
|
339
|
+
if (!credential) {
|
|
340
|
+
await context.publish({ update: clear });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const expectedScope = catalogScope(credential);
|
|
344
|
+
let restored: VariantProjection | undefined;
|
|
345
|
+
try { restored = restoredProjection(context.stored, credential); }
|
|
346
|
+
catch { /* A bad cache is never used as run data. The current good state is kept. */ }
|
|
347
|
+
if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
|
|
348
|
+
const restoredModels = build(restored, credential);
|
|
349
|
+
const checkedAt = context.stored?.checkedAt;
|
|
350
|
+
const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
|
|
351
|
+
const accepted = await context.publish({ update: () => {
|
|
352
|
+
projection = restored; models = restoredModels; scope = expectedScope; modelCredential = credential;
|
|
353
|
+
checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl;
|
|
354
|
+
} });
|
|
355
|
+
if (!accepted) return;
|
|
356
|
+
} else if (scope !== expectedScope || endpointOverride !== context.baseUrl) {
|
|
357
|
+
const accepted = await context.publish({ update: () => { clear(); endpointOverride = context.baseUrl; } });
|
|
358
|
+
if (!accepted) return;
|
|
359
|
+
}
|
|
360
|
+
if (!context.allowNetwork) return;
|
|
361
|
+
const ageMs = now() - checkedAtMs;
|
|
362
|
+
if (!context.force && scope === expectedScope && projection && checkedAtMs > 0 && ageMs >= 0 && ageMs < freshnessMs) return;
|
|
363
|
+
const catalog = await awaitWithSignal(options.fetchCatalog(credential, context.signal), context.signal);
|
|
364
|
+
context.signal.throwIfAborted();
|
|
365
|
+
const nextProjection = projectModelVariants(catalog);
|
|
366
|
+
const nextModels = build(nextProjection, credential);
|
|
367
|
+
const checkedAt = now();
|
|
368
|
+
if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) throw new Error("Invalid model cache check time.");
|
|
369
|
+
await context.publish({
|
|
370
|
+
persist: { models: build(nextProjection, credential, true), checkedAt },
|
|
371
|
+
update: () => {
|
|
372
|
+
projection = nextProjection; models = nextModels; scope = expectedScope; modelCredential = credential;
|
|
373
|
+
checkedAtMs = checkedAt; endpointOverride = context.baseUrl;
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampThinkingLevel,
|
|
3
|
+
type AssistantMessageEventStream, type Model, type Provider, type SimpleStreamOptions, type TranscriptContext,
|
|
4
|
+
} from "@earendil-works/pi-ai";
|
|
5
|
+
import { DevinClient, type DevinClientOptions } from "../core/client.ts";
|
|
6
|
+
import { assertCanInfer } from "../core/auth/access.ts";
|
|
7
|
+
import { ChatAccumulator, chatRequest } from "../core/inference/chat.ts";
|
|
8
|
+
import { applyTeamModelPolicy } from "../core/models/access.ts";
|
|
9
|
+
import type { BackendModelSelection, EffectiveThinkingLevel } from "../core/models/variants.ts";
|
|
10
|
+
import { createFingerprintProvider, type FingerprintProvider } from "../core/protocol/fingerprint.ts";
|
|
11
|
+
import { CHAT_METHOD } from "../core/protocol/protobuf.ts";
|
|
12
|
+
import { awaitWithSignal, endpoint, isAuthenticationError, RPCError, safeError } from "../core/protocol/transport.ts";
|
|
13
|
+
import { object, requiredString, tokenCount, type Credential, type JsonObject } from "../core/types.ts";
|
|
14
|
+
import { createDevinAccountAuth } from "./auth.ts";
|
|
15
|
+
import { DevinLifecycle, type RequestLease, type SessionBinding } from "./lifecycle.ts";
|
|
16
|
+
import { toDevinTranscript } from "./messages.ts";
|
|
17
|
+
import { catalogScope, createDevinModelSource, DEVIN_API, hasExtendedPromptCacheRetention, isFusionModelID, type FusionChoices, type FusionSelection } from "./models.ts";
|
|
18
|
+
import { FUSION_LEAD_GUIDANCE } from "./sidekick.ts";
|
|
19
|
+
import { DevinResponseStream } from "./stream.ts";
|
|
20
|
+
import { accountUsage, type AccountUsage } from "./usage.ts";
|
|
21
|
+
|
|
22
|
+
type LoginConfiguration = NonNullable<Parameters<typeof createDevinAccountAuth>[0]>["loginOptions"];
|
|
23
|
+
|
|
24
|
+
/** Like Pi's built-in providers: the caller option wins; otherwise only PI_CACHE_RETENTION=long (provider env first) means long retention. */
|
|
25
|
+
function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
|
|
26
|
+
if (options.cacheRetention) return options.cacheRetention;
|
|
27
|
+
return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
|
|
28
|
+
}
|
|
29
|
+
export interface DevinProvider extends Provider<typeof DEVIN_API> {
|
|
30
|
+
refreshModels: NonNullable<Provider<typeof DEVIN_API>["refreshModels"]>;
|
|
31
|
+
readonly lifecycle: DevinLifecycle;
|
|
32
|
+
attachSession(binding: SessionBinding): void;
|
|
33
|
+
getFusionChoices(): FusionChoices | undefined;
|
|
34
|
+
/** Rebuilds the model list from the current catalog without network after the saved Fusion choice or display settings change. */
|
|
35
|
+
rebuildModels(): void;
|
|
36
|
+
/** Quota from the last account status received. Makes no request; undefined if none was received. */
|
|
37
|
+
getAccountUsage(): AccountUsage | undefined;
|
|
38
|
+
shutdown(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createDevinProvider(options: {
|
|
42
|
+
fetch?: typeof fetch;
|
|
43
|
+
fingerprint?: (credential: Credential) => FingerprintProvider;
|
|
44
|
+
http?: DevinClientOptions["http"];
|
|
45
|
+
loginOptions?: LoginConfiguration;
|
|
46
|
+
maxModelCalls?: number;
|
|
47
|
+
/** Reads the saved Fusion choice. false (sidekick process) creates no Fusion entry. */
|
|
48
|
+
fusion?: false | (() => FusionSelection | undefined);
|
|
49
|
+
/** Reads the saved recommended filter setting. */
|
|
50
|
+
recommendedOnly?: () => boolean;
|
|
51
|
+
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
52
|
+
listsAllExactModels?: boolean;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
} = {}): DevinProvider {
|
|
55
|
+
const lifecycle = new DevinLifecycle(options.maxModelCalls);
|
|
56
|
+
let lifetime = new AbortController();
|
|
57
|
+
const clients = new Set<DevinClient>();
|
|
58
|
+
const fingerprints = new Map<string, FingerprintProvider>();
|
|
59
|
+
const accounts = new Map<string, { ownerHash: string; status: JsonObject }>();
|
|
60
|
+
const now = options.now ?? Date.now;
|
|
61
|
+
let latestUsage: AccountUsage | undefined;
|
|
62
|
+
const clientFor = (credential: Credential, extra: DevinClientOptions = {}): DevinClient => {
|
|
63
|
+
const key = catalogScope(credential);
|
|
64
|
+
let fingerprint = fingerprints.get(key);
|
|
65
|
+
if (!fingerprint) {
|
|
66
|
+
if (fingerprints.size >= 16) fingerprints.delete(fingerprints.keys().next().value!);
|
|
67
|
+
fingerprint = options.fingerprint?.(credential) ?? createFingerprintProvider(credential.sessionToken);
|
|
68
|
+
fingerprints.set(key, fingerprint);
|
|
69
|
+
}
|
|
70
|
+
const fetch = extra.fetch ?? options.fetch;
|
|
71
|
+
const client = new DevinClient(credential, { ...extra, fingerprint, fetch, ...(!fetch && options.http ? { http: options.http } : {}) });
|
|
72
|
+
const close = client.transport.close.bind(client.transport);
|
|
73
|
+
let hasClosed = false;
|
|
74
|
+
client.transport.close = () => { if (!hasClosed) { hasClosed = true; clients.delete(client); close(); } };
|
|
75
|
+
clients.add(client);
|
|
76
|
+
return client;
|
|
77
|
+
};
|
|
78
|
+
const rememberAccount = (credential: Credential, ownerHash: string, status: JsonObject) => {
|
|
79
|
+
if (lifetime.signal.aborted) return;
|
|
80
|
+
if (accounts.size >= 16) accounts.delete(accounts.keys().next().value!);
|
|
81
|
+
accounts.set(catalogScope(credential), { ownerHash, status });
|
|
82
|
+
latestUsage = accountUsage(status, now());
|
|
83
|
+
};
|
|
84
|
+
const auth = createDevinAccountAuth({
|
|
85
|
+
loginOptions: { ...options.loginOptions, fetch: options.loginOptions?.fetch ?? options.fetch },
|
|
86
|
+
createClient: (credential) => clientFor(credential, { profile: "login" }),
|
|
87
|
+
getSignal: () => lifetime.signal,
|
|
88
|
+
onValidated: rememberAccount,
|
|
89
|
+
onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); },
|
|
90
|
+
onLogin: () => {
|
|
91
|
+
accounts.clear(); fingerprints.clear(); latestUsage = undefined; source.invalidate();
|
|
92
|
+
lifecycle.abort(new DOMException("The Devin account was re-authenticated.", "AbortError"));
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const authenticationFailed = (credential: Credential, error: unknown, generation: number) => {
|
|
96
|
+
if (!isAuthenticationError(error)) return;
|
|
97
|
+
auth.invalidate(credential, error, generation);
|
|
98
|
+
};
|
|
99
|
+
const source = createDevinModelSource({
|
|
100
|
+
fusion: options.fusion,
|
|
101
|
+
recommendedOnly: options.recommendedOnly,
|
|
102
|
+
listsAllExactModels: options.listsAllExactModels,
|
|
103
|
+
credentialFromHost: (credential) => credential?.type === "oauth" ? auth.credentialFromHost(credential) : undefined,
|
|
104
|
+
fetchCatalog: async (credential, signal) => {
|
|
105
|
+
const generation = auth.generation;
|
|
106
|
+
auth.assertUsable(credential);
|
|
107
|
+
const client = clientFor(credential);
|
|
108
|
+
try {
|
|
109
|
+
const catalog = await client.catalog(signal);
|
|
110
|
+
applyTeamModelPolicy(catalog.models, await client.teamSettings(signal));
|
|
111
|
+
return catalog;
|
|
112
|
+
} catch (error) { authenticationFailed(credential, error, generation); throw error; }
|
|
113
|
+
finally { client.transport.close(); }
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const infer = (model: Model<typeof DEVIN_API>, context: TranscriptContext, requestOptions: SimpleStreamOptions = {}) => {
|
|
117
|
+
const effectiveLevel = (requestOptions.reasoning ?? clampThinkingLevel(model, "off")) as EffectiveThinkingLevel;
|
|
118
|
+
const response = new DevinResponseStream(model, "");
|
|
119
|
+
void (async () => {
|
|
120
|
+
let client: DevinClient | undefined, lease: RequestLease | undefined;
|
|
121
|
+
let credential: Credential | undefined;
|
|
122
|
+
const accumulator = new ChatAccumulator();
|
|
123
|
+
let actualUID = "", isRedacted = false;
|
|
124
|
+
const generation = auth.generation;
|
|
125
|
+
const signal = AbortSignal.any([lifetime.signal, ...(requestOptions.signal ? [requestOptions.signal] : [])]);
|
|
126
|
+
try {
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
credential = { version: 1, sessionToken: requiredString(requestOptions.apiKey, "Devin account authentication (/login required)"), apiServerURL: endpoint(model.baseUrl), createdAt: new Date().toISOString() };
|
|
129
|
+
// Pi can override the OAuth value with an apiKey in models.json or a caller option. Only OAuth sessions are accepted.
|
|
130
|
+
auth.assertOAuthSession(credential);
|
|
131
|
+
auth.assertUsable(credential);
|
|
132
|
+
let selected = source.resolve(model.id, effectiveLevel, credential);
|
|
133
|
+
actualUID = selected.uid;
|
|
134
|
+
response.message.providerThinkingLevel = actualUID;
|
|
135
|
+
if (requestOptions.maxRetries !== undefined && requestOptions.maxRetries !== 0) throw new Error("Devin uses Pi host retries. Only provider maxRetries 0 is supported.");
|
|
136
|
+
if (requestOptions.transport && !["auto", "sse"].includes(requestOptions.transport)) throw new Error("Devin supports HTTP Connect streams only.");
|
|
137
|
+
const cacheRetention = resolveCacheRetention(requestOptions);
|
|
138
|
+
const assertCacheRetention = (selection: BackendModelSelection) => {
|
|
139
|
+
if (cacheRetention === "long" && !hasExtendedPromptCacheRetention(selection.variant)) {
|
|
140
|
+
throw new Error("Long cache lifetime is not confirmed for this Devin model. Long retention is supported only for models whose catalog Prompt Cache Retention is 24h.");
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
assertCacheRetention(selected);
|
|
144
|
+
let account = accounts.get(catalogScope(credential));
|
|
145
|
+
if (!account) {
|
|
146
|
+
const probe = clientFor(credential, { profile: "login", fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs });
|
|
147
|
+
try {
|
|
148
|
+
const status = await probe.status(signal);
|
|
149
|
+
signal.throwIfAborted();
|
|
150
|
+
if (generation !== auth.generation) throw new DOMException("The Devin authentication generation changed.", "AbortError");
|
|
151
|
+
auth.assertUsable(credential);
|
|
152
|
+
rememberAccount(credential, requiredString(probe.accountIdentityHash, "Devin account scope"), status);
|
|
153
|
+
account = accounts.get(catalogScope(credential));
|
|
154
|
+
} finally { probe.transport.close(); }
|
|
155
|
+
}
|
|
156
|
+
if (!account) throw new Error("The Devin account check was interrupted.");
|
|
157
|
+
assertCanInfer(account.status);
|
|
158
|
+
lease = await lifecycle.acquire(account.ownerHash, signal);
|
|
159
|
+
const currentLease = lease;
|
|
160
|
+
const transcript = toDevinTranscript(context, selected.model);
|
|
161
|
+
if (isFusionModelID(model.id)) transcript.systemPrompt = [transcript.systemPrompt, FUSION_LEAD_GUIDANCE].filter(Boolean).join("\n\n");
|
|
162
|
+
const maximum = requestOptions.maxTokens === undefined ? selected.model.maxOutputTokens : tokenCount(requestOptions.maxTokens, "maxTokens");
|
|
163
|
+
if (maximum < 1 || maximum > selected.model.maxOutputTokens) throw new Error("The output limit is outside the selected Devin model range.");
|
|
164
|
+
client = clientFor(credential, { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs,
|
|
165
|
+
onRequest: (attempt) => { if (attempt.isMetered) currentLease.consume(); },
|
|
166
|
+
});
|
|
167
|
+
currentLease.addCleanup(() => client!.transport.close());
|
|
168
|
+
const request = chatRequest({ metadata: client.metadata(), model: selected.uid, ...transcript, maxTokens: maximum,
|
|
169
|
+
cascadeId: lease.cascadeId, trajectoryId: lease.trajectoryId, stepIndex: lease.stepIndex,
|
|
170
|
+
executionId: lease.executionId, clientSessionId: requestOptions.sessionId ?? lease.sessionId,
|
|
171
|
+
isAuxiliary: lease.isAuxiliary, isRecovery: lease.isRecovery, includeContext: false,
|
|
172
|
+
});
|
|
173
|
+
if (requestOptions.temperature !== undefined) {
|
|
174
|
+
if (!Number.isFinite(requestOptions.temperature) || requestOptions.temperature < 0) throw new Error("temperature must be a finite value of 0 or more.");
|
|
175
|
+
object(request.configuration, "configuration").temperature = requestOptions.temperature;
|
|
176
|
+
}
|
|
177
|
+
if (cacheRetention === "none") for (const prompt of request.chatMessagePrompts as JsonObject[]) delete prompt.promptCacheOptions;
|
|
178
|
+
const identity = JSON.stringify([request.cascadeId, request.trajectoryReference, request.executionId, request.clientSessionId]);
|
|
179
|
+
for await (const _frame of client.transport.stream(CHAT_METHOD, request, lease.signal, {
|
|
180
|
+
headers: requestOptions.headers,
|
|
181
|
+
onPayload: async (payload, scopeSignal) => {
|
|
182
|
+
const replacement = requestOptions.onPayload ? await awaitWithSignal(Promise.resolve(requestOptions.onPayload(payload, model)), scopeSignal) : undefined;
|
|
183
|
+
const finalPayload = replacement === undefined ? payload : object(replacement, "provider payload");
|
|
184
|
+
if (JSON.stringify([finalPayload.cascadeId, finalPayload.trajectoryReference, finalPayload.executionId, finalPayload.clientSessionId]) !== identity) {
|
|
185
|
+
throw new Error("A payload hook cannot change the owned run, session or step identifiers.");
|
|
186
|
+
}
|
|
187
|
+
actualUID = requiredString(finalPayload.chatModelUid, "chatModelUid");
|
|
188
|
+
if (actualUID !== selected.uid) {
|
|
189
|
+
// An exact UID hidden from the list is re-resolved at that UID's level when the current catalog has it.
|
|
190
|
+
if (!source.exactLevel(actualUID)) throw new Error("A payload hook selected an unavailable model UID.");
|
|
191
|
+
selected = source.resolveUID(actualUID, credential!);
|
|
192
|
+
toDevinTranscript(context, selected.model); // A model change re-checks existing image and tool limits.
|
|
193
|
+
assertCacheRetention(selected);
|
|
194
|
+
}
|
|
195
|
+
const outputLimit = tokenCount(object(finalPayload.configuration, "configuration").maxTokens, "maxTokens");
|
|
196
|
+
if (outputLimit < 1 || outputLimit > selected.model.maxOutputTokens) throw new Error("The payload hook output limit is outside the model range.");
|
|
197
|
+
response.message.providerThinkingLevel = actualUID;
|
|
198
|
+
return finalPayload;
|
|
199
|
+
},
|
|
200
|
+
onResponse: async (info, scopeSignal) => {
|
|
201
|
+
if (info.status === 401) authenticationFailed(credential!, new RPCError("unauthenticated", "HTTP 401", 401), generation);
|
|
202
|
+
if (requestOptions.onResponse) await awaitWithSignal(Promise.resolve(requestOptions.onResponse(info, model)), scopeSignal);
|
|
203
|
+
},
|
|
204
|
+
onFrame: async (frame, scopeSignal) => {
|
|
205
|
+
if (requestOptions.onProviderStreamEvent) await awaitWithSignal(Promise.resolve(requestOptions.onProviderStreamEvent(structuredClone(frame.data), model)), scopeSignal);
|
|
206
|
+
if (frame.isEnd && frame.data.error) return; // Connect error classification belongs to the transport.
|
|
207
|
+
if (frame.data.redact === true) { isRedacted = true; response.redact(); }
|
|
208
|
+
accumulator.accept(frame.data, frame.isEnd);
|
|
209
|
+
if (!frame.isEnd) response.accept(frame.data, accumulator);
|
|
210
|
+
},
|
|
211
|
+
})) { /* A frame handling failure also ends the request inside the transport first. */ }
|
|
212
|
+
const result = accumulator.finish();
|
|
213
|
+
result.message.modelUid = actualUID;
|
|
214
|
+
response.finish(result);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (credential) authenticationFailed(credential, error, generation);
|
|
217
|
+
accumulator.message.modelUid = actualUID || undefined;
|
|
218
|
+
response.fail(safeError(error, credential ? [credential.sessionToken] : []), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
|
|
219
|
+
} finally { client?.transport.close(); lease?.release(); }
|
|
220
|
+
})();
|
|
221
|
+
return response.stream;
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
id: "devin", name: "Devin", auth: { oauth: auth }, lifecycle,
|
|
225
|
+
getModels: source.getModels, filterModels: source.filterModels,
|
|
226
|
+
getFusionChoices: source.getFusionChoices, rebuildModels: source.rebuildModels,
|
|
227
|
+
getAccountUsage: () => latestUsage ? { ...latestUsage } : undefined,
|
|
228
|
+
refreshModels: (context) => source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }),
|
|
229
|
+
stream: (model, context, requestOptions) => infer(model, context, requestOptions), streamSimple: (model, context, requestOptions) => infer(model, context, requestOptions),
|
|
230
|
+
attachSession(binding) {
|
|
231
|
+
if (lifetime.signal.aborted) lifetime = new AbortController();
|
|
232
|
+
lifecycle.attach(binding);
|
|
233
|
+
},
|
|
234
|
+
shutdown() {
|
|
235
|
+
lifetime.abort(new DOMException("The Devin provider shut down.", "AbortError"));
|
|
236
|
+
lifecycle.shutdown();
|
|
237
|
+
for (const client of [...clients]) client.transport.close();
|
|
238
|
+
fingerprints.clear(); accounts.clear(); latestUsage = undefined;
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|