@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { VariantProjection } from "../../../core/models/variants.ts";
|
|
2
|
+
import { DEVIN_API, FUSION_SIDEKICK_UID } from "./constants.ts";
|
|
3
|
+
import { byModelID, groupDisplayNames, isUsable, publicModelIDs, publishedVariants, type VariantGroup } from "./ids.ts";
|
|
4
|
+
import type { DevinModel, FusionChoices, FusionSelection } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fusion candidates. The lead must call the sidekick tool and the sidekick works with Pi tools, so
|
|
8
|
+
* only models that support tool calls qualify. All variants of a published group share capabilities.
|
|
9
|
+
*/
|
|
10
|
+
export function fusionChoices(projection: VariantProjection): FusionChoices {
|
|
11
|
+
const names = groupDisplayNames(projection), ids = publicModelIDs(projection);
|
|
12
|
+
const leads = projection.groups.filter((group) => group.canPublish && publishedVariants(group)[0]?.model.supportsToolCalls)
|
|
13
|
+
.map((group) => ({ id: ids.groups.get(group.id)!, name: names.get(group.id)!,
|
|
14
|
+
...(group.variants.some((variant) => variant.model.isRecommended) ? { recommended: true as const } : {}) }));
|
|
15
|
+
const groupOf = new Map<string, VariantGroup>();
|
|
16
|
+
for (const group of projection.groups) for (const variant of group.variants) groupOf.set(variant.model.id, group);
|
|
17
|
+
const sidekicks = projection.variants.filter((variant) => isUsable(variant) && variant.model.supportsToolCalls).sort(byModelID)
|
|
18
|
+
.map((variant) => {
|
|
19
|
+
const group = groupOf.get(variant.model.id);
|
|
20
|
+
return { id: ids.variants.get(variant.model.id)!, name: variant.model.name, groupName: group ? names.get(group.id)! : variant.model.name, level: variant.level!,
|
|
21
|
+
...(variant.model.isRecommended ? { recommended: true as const } : {}) };
|
|
22
|
+
});
|
|
23
|
+
return { leads, sidekicks };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Saved values missing from the current list fall back to the defaults per item. Without defaults, no Fusion entry is built. */
|
|
27
|
+
export function effectiveFusion(projection: VariantProjection, requested: FusionSelection | undefined): FusionSelection | undefined {
|
|
28
|
+
const { leads, sidekicks } = fusionChoices(projection), ids = publicModelIDs(projection);
|
|
29
|
+
const defaultSidekick = ids.variants.get(FUSION_SIDEKICK_UID);
|
|
30
|
+
const defaultGroup = projection.groups.find((group) => group.variants.some((variant) => variant.model.id === FUSION_SIDEKICK_UID));
|
|
31
|
+
const defaultLead = defaultGroup && ids.groups.get(defaultGroup.id);
|
|
32
|
+
// A choice saved in the legacy format (`family:…`, `uid:…`) is read as the same model's new ID.
|
|
33
|
+
const current = (id: string | undefined) => id?.startsWith("family:") ? ids.groups.get(id)
|
|
34
|
+
: id?.startsWith("uid:") ? ids.variants.get(decodeURIComponent(id.slice("uid:".length))) : id;
|
|
35
|
+
const pick = (options: readonly { id: string }[], wanted: string | undefined, fallback: string | undefined) =>
|
|
36
|
+
options.find((option) => option.id === current(wanted))?.id ?? options.find((option) => option.id === fallback)?.id;
|
|
37
|
+
const leadID = pick(leads, requested?.leadID, defaultLead), sidekickID = pick(sidekicks, requested?.sidekickID, defaultSidekick);
|
|
38
|
+
return leadID && sidekickID ? { leadID, sidekickID } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The Fusion pair of the lead's model. The host may copy model objects in its settings layer, so the pair is looked up by ID in the current list. */
|
|
42
|
+
export function fusionOf(models: readonly DevinModel[], model: { provider?: string; api?: string; id: string } | undefined): FusionSelection | undefined {
|
|
43
|
+
if (model?.provider !== "devin" || model.api !== DEVIN_API) return undefined;
|
|
44
|
+
return models.find((candidate) => candidate.id === model.id)?.devin.fusion;
|
|
45
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ModelVariant, VariantProjection } from "../../../core/models/variants.ts";
|
|
2
|
+
import { FUSION_MODEL_ID } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
export type VariantGroup = VariantProjection["groups"][number];
|
|
5
|
+
export const byModelID = (a: ModelVariant, b: ModelVariant) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0;
|
|
6
|
+
export const isUsable = (variant: ModelVariant) => variant.level !== undefined && !variant.unsupported;
|
|
7
|
+
export const publishedVariants = (group: VariantGroup) => group.variants.filter(isUsable).sort(byModelID);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dimension labels that tell groups apart: only dimensions whose values differ among published groups of the same family name.
|
|
11
|
+
* When the catalog value name is empty, 0 counts as the default and is omitted; an enabled value uses the dimension name.
|
|
12
|
+
*/
|
|
13
|
+
function groupDistinctParts(projection: VariantProjection): Map<string, string[]> {
|
|
14
|
+
const families = new Map<string, VariantGroup[]>();
|
|
15
|
+
for (const group of projection.groups) families.set(group.name, [...(families.get(group.name) ?? []), group]);
|
|
16
|
+
const result = new Map<string, string[]>();
|
|
17
|
+
for (const groups of families.values()) {
|
|
18
|
+
const peers = groups.filter((group) => group.canPublish).length > 1 ? groups.filter((group) => group.canPublish) : groups;
|
|
19
|
+
const values = (key: string) => new Set(peers.map((group) => {
|
|
20
|
+
const dimension = group.dimensions.find((entry) => entry.key === key);
|
|
21
|
+
return dimension ? `${dimension.order}\0${dimension.name}` : "";
|
|
22
|
+
}));
|
|
23
|
+
for (const group of groups) {
|
|
24
|
+
const parts = group.dimensions.filter((dimension) => values(dimension.key).size > 1).flatMap((dimension) => {
|
|
25
|
+
if (dimension.name) return [`${dimension.key} ${dimension.name}`];
|
|
26
|
+
if (dimension.order === 0) return [];
|
|
27
|
+
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));
|
|
28
|
+
return [orders.size > 1 ? `${dimension.key} ${dimension.order}` : dimension.key];
|
|
29
|
+
});
|
|
30
|
+
result.set(group.id, parts);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Human-readable group name. Example: `Claude Opus 4.8 · Fast Mode`. */
|
|
37
|
+
export function groupDisplayNames(projection: VariantProjection): Map<string, string> {
|
|
38
|
+
const parts = groupDistinctParts(projection);
|
|
39
|
+
return new Map(projection.groups.map((group) => [group.id, [group.name, ...parts.get(group.id)!].join(" · ")]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
+
|
|
44
|
+
/** Model IDs shown in Pi lists. Groups are keyed by their internal `family:` key, variants by catalog UID. */
|
|
45
|
+
export interface PublicModelIDs {
|
|
46
|
+
groups: ReadonlyMap<string, string>;
|
|
47
|
+
variants: ReadonlyMap<string, string>;
|
|
48
|
+
targets: ReadonlyMap<string, { group: VariantGroup } | { variant: ModelVariant }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const publicIDCache = new WeakMap<VariantProjection, PublicModelIDs>();
|
|
52
|
+
/**
|
|
53
|
+
* Pi `/model` shows IDs, not model names, so the IDs are made human-readable.
|
|
54
|
+
* Published groups use `familyID[-differing dimensions]` (e.g. `claude-opus-4.8-fast-mode`); variants use the catalog UID (e.g. `swe-2-high`).
|
|
55
|
+
* The same catalog always yields the same IDs regardless of display mode. On a clash, groups win and the variant gets `-exact`.
|
|
56
|
+
*/
|
|
57
|
+
export function publicModelIDs(projection: VariantProjection): PublicModelIDs {
|
|
58
|
+
const cached = publicIDCache.get(projection);
|
|
59
|
+
if (cached) return cached;
|
|
60
|
+
const parts = groupDistinctParts(projection), taken = new Set([FUSION_MODEL_ID]);
|
|
61
|
+
const groups = new Map<string, string>(), variants = new Map<string, string>(), targets = new Map<string, { group: VariantGroup } | { variant: ModelVariant }>();
|
|
62
|
+
const claim = (base: string) => {
|
|
63
|
+
let id = base;
|
|
64
|
+
for (let suffix = 2; taken.has(id); suffix++) id = `${base}-${suffix}`;
|
|
65
|
+
taken.add(id);
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
for (const group of projection.groups.filter((entry) => entry.canPublish).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
69
|
+
const id = claim([slug(group.family), ...parts.get(group.id)!.map(slug)].filter(Boolean).join("-") || "model");
|
|
70
|
+
groups.set(group.id, id); targets.set(id, { group });
|
|
71
|
+
}
|
|
72
|
+
for (const variant of projection.variants.filter(isUsable).sort(byModelID)) {
|
|
73
|
+
const id = claim(taken.has(variant.model.id) ? `${variant.model.id}-exact` : variant.model.id);
|
|
74
|
+
variants.set(variant.model.id, id); targets.set(id, { variant });
|
|
75
|
+
}
|
|
76
|
+
const result = { groups, variants, targets };
|
|
77
|
+
publicIDCache.set(projection, result);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EFFECTIVE_THINKING_LEVELS, type EffectiveThinkingLevel, type ModelVariant, type VariantProjection } from "../../../core/models/variants.ts";
|
|
3
|
+
import type { Credential } from "../../../core/types.ts";
|
|
4
|
+
import { CATALOG_VERSION, DEVIN_API, FUSION_MODEL_ID } from "./constants.ts";
|
|
5
|
+
import { effectiveFusion } from "./fusion.ts";
|
|
6
|
+
import { groupDisplayNames, isUsable, publicModelIDs, publishedVariants } from "./ids.ts";
|
|
7
|
+
import type { DevinModel, FusionSelection } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Options for projecting the model list. Defaults: group list, Fusion included, recommended filter off. */
|
|
10
|
+
export interface ProjectionOptions {
|
|
11
|
+
/** Saved Fusion choice. false (sidekick child Pi) creates no Fusion entry. */
|
|
12
|
+
fusion?: FusionSelection | false;
|
|
13
|
+
/** Lists only models with a variant marked as recommended. Groups stay groups. The Fusion entry is unaffected. */
|
|
14
|
+
recommendedOnly?: boolean;
|
|
15
|
+
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
16
|
+
listsAllExactModels?: boolean;
|
|
17
|
+
/** Account identity hash recorded with every model. */
|
|
18
|
+
account?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A model whose catalog entry marks 24-hour prompt cache retention as a variant attribute.
|
|
23
|
+
* The request wire has no retention field, so this flag is the only basis for allowing long retention.
|
|
24
|
+
*/
|
|
25
|
+
export function hasExtendedPromptCacheRetention(variant: ModelVariant): boolean {
|
|
26
|
+
return variant.dimensions.some((dimension) => dimension.key === "Prompt Cache Retention" && dimension.name === "24h");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scope of one credential on one endpoint. Credentials reaching here already carry a validated endpoint origin. */
|
|
30
|
+
export function catalogScope(credential: Credential): string {
|
|
31
|
+
return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function thinkingMap(mappings: Partial<Record<EffectiveThinkingLevel, string>>) {
|
|
35
|
+
return Object.fromEntries(EFFECTIVE_THINKING_LEVELS.map((level) => [level, mappings[level] ?? null]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential, account?: string): DevinModel {
|
|
39
|
+
const first = variants[0].model;
|
|
40
|
+
return {
|
|
41
|
+
id, name, api: DEVIN_API, provider: "devin", baseUrl: credential.apiServerURL,
|
|
42
|
+
reasoning: Object.keys(mappings).some((level) => level !== "off"), thinkingLevelMap: thinkingMap(mappings),
|
|
43
|
+
input: first.supportsImages ? ["text", "image"] : ["text"],
|
|
44
|
+
contextWindow: first.contextWindow, maxTokens: first.maxOutputTokens,
|
|
45
|
+
// Placeholder for Pi's required numeric fields. It does not mean free or a real USD price.
|
|
46
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
47
|
+
devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config), ...(account ? { account } : {}) },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Exact UIDs covered by a published group are hidden and chosen with `/thinking`. Their catalog configs are stored on the group model.
|
|
53
|
+
* `recommendedOnly` keeps only groups and uncovered exact UIDs that hold a recommended variant, so `/thinking` still picks the UID.
|
|
54
|
+
* Without any recommended variant, it falls back to the default list.
|
|
55
|
+
* `listsAllExactModels` is only for processes that pick exact UIDs directly, such as the sidekick child.
|
|
56
|
+
*/
|
|
57
|
+
export function modelsFromProjection(projection: VariantProjection, credential: Credential, options: ProjectionOptions = {}): DevinModel[] {
|
|
58
|
+
const models: DevinModel[] = [], names = groupDisplayNames(projection), ids = publicModelIDs(projection);
|
|
59
|
+
const published = projection.groups.filter((group) => group.canPublish);
|
|
60
|
+
const recommendedUIDs = new Set(projection.variants.filter((variant) => isUsable(variant) && variant.model.isRecommended).map((variant) => variant.model.id));
|
|
61
|
+
// Fall back to the default projection so accounts without recommended flags never get an empty list.
|
|
62
|
+
const isRecommendedOnly = options.recommendedOnly === true && recommendedUIDs.size > 0;
|
|
63
|
+
const isListed = (uids: readonly string[]) => !isRecommendedOnly || uids.some((uid) => recommendedUIDs.has(uid));
|
|
64
|
+
for (const group of published) {
|
|
65
|
+
if (!isListed(Object.values(group.mappings))) continue;
|
|
66
|
+
const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential, options.account);
|
|
67
|
+
if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
|
|
68
|
+
models.push(model);
|
|
69
|
+
}
|
|
70
|
+
const covered = new Set(published.flatMap((group) => Object.values(group.mappings)));
|
|
71
|
+
const exact = projection.variants.filter((variant) => isUsable(variant) && isListed([variant.model.id])
|
|
72
|
+
&& (options.listsAllExactModels === true || !covered.has(variant.model.id)));
|
|
73
|
+
const listedNames = new Set(models.map((model) => model.name));
|
|
74
|
+
for (const variant of exact) {
|
|
75
|
+
const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
|
|
76
|
+
const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential, options.account);
|
|
77
|
+
if (variant.model.isRecommended) model.devin.recommended = true;
|
|
78
|
+
models.push(model);
|
|
79
|
+
}
|
|
80
|
+
const fusion = options.fusion;
|
|
81
|
+
const pair = fusion === false ? undefined : effectiveFusion(projection, fusion);
|
|
82
|
+
if (pair) {
|
|
83
|
+
const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
|
|
84
|
+
const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
|
|
85
|
+
const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential, options.account);
|
|
86
|
+
model.devin.fusion = pair;
|
|
87
|
+
models.push(model);
|
|
88
|
+
}
|
|
89
|
+
models.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
90
|
+
if (models.length) models[0].devin.unavailable = projection.variants.filter((variant) => variant.unsupported)
|
|
91
|
+
.sort((a, b) => a.model.id < b.model.id ? -1 : a.model.id > b.model.id ? 1 : 0).map((variant) => variant.config);
|
|
92
|
+
return models;
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { applyTeamModelPolicy } from "../../../core/models/access.ts";
|
|
3
|
+
import { DevinClients } from "../clients/service.ts";
|
|
4
|
+
import type { RemoteCatalogShape } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** The remote side of the catalog query: one lookup of the catalog and the team settings for a credential. */
|
|
7
|
+
export class RemoteCatalog extends Context.Service<RemoteCatalog, RemoteCatalogShape>()("pi-devin/RemoteCatalog") {}
|
|
8
|
+
|
|
9
|
+
export const RemoteCatalogLive = Layer.effect(RemoteCatalog, Effect.map(DevinClients, (clients) => ({
|
|
10
|
+
fetch: (credential) => Effect.scoped(Effect.gen(function* () {
|
|
11
|
+
const client = yield* clients.open(credential);
|
|
12
|
+
const catalog = yield* client.catalog;
|
|
13
|
+
return { ...catalog, models: applyTeamModelPolicy(catalog.models, yield* client.teamSettings) };
|
|
14
|
+
})),
|
|
15
|
+
} satisfies RemoteCatalogShape)));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ModelsStoreEntry } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Effect, Predicate } from "effect";
|
|
3
|
+
import { restoreModelVariants, type VariantProjection } from "../../../core/models/variants.ts";
|
|
4
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
5
|
+
import { CATALOG_VERSION, DEVIN_API } from "./constants.ts";
|
|
6
|
+
import { catalogScope } from "./projection.ts";
|
|
7
|
+
import type { DevinModel } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const ACCOUNT_HASH = /^[a-f0-9]{64}$/;
|
|
10
|
+
export interface RestoredCatalog { projection: VariantProjection; account?: string; isSameSession: boolean }
|
|
11
|
+
|
|
12
|
+
/** Every stored entry's configs when the whole store belongs to one scope and one account; anything mixed or edited is ignored. */
|
|
13
|
+
function storedConfigs(stored: Readonly<ModelsStoreEntry>, credential: Credential): { configs: JsonObject[]; scope: unknown; account: unknown } | undefined {
|
|
14
|
+
const configs = new Map<string, JsonObject>();
|
|
15
|
+
let storedScope: unknown, storedAccount: unknown;
|
|
16
|
+
for (const [index, model] of stored.models.entries()) {
|
|
17
|
+
const metadata = (model as DevinModel).devin as unknown;
|
|
18
|
+
if (!Predicate.isObject(metadata)) return undefined;
|
|
19
|
+
if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
|
|
20
|
+
|| metadata.version !== CATALOG_VERSION || !Predicate.isString(metadata.scope)) return undefined;
|
|
21
|
+
if (metadata.account !== undefined && (!Predicate.isString(metadata.account) || !ACCOUNT_HASH.test(metadata.account))) return undefined;
|
|
22
|
+
if (index === 0) { storedScope = metadata.scope; storedAccount = metadata.account; }
|
|
23
|
+
else if (metadata.scope !== storedScope || metadata.account !== storedAccount) return undefined;
|
|
24
|
+
if (!Array.isArray(metadata.configs)) return undefined;
|
|
25
|
+
for (const value of [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])]) {
|
|
26
|
+
if (!Predicate.isObject(value) || !Predicate.isString(value.modelUid)) return undefined;
|
|
27
|
+
const config = value as JsonObject;
|
|
28
|
+
const previous = configs.get(value.modelUid);
|
|
29
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(config)) return undefined;
|
|
30
|
+
configs.set(value.modelUid, config);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { configs: [...configs.values()], scope: storedScope, account: storedAccount };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Restores the stored catalog of the same session, or of an earlier session of an account this process verified for `account`.
|
|
38
|
+
* Every entry must share one scope and one account, so a mixed or edited cache is never partly applied. A bad cache yields none.
|
|
39
|
+
*/
|
|
40
|
+
export function restoredCatalog(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential, account: string | undefined): Effect.Effect<RestoredCatalog | undefined> {
|
|
41
|
+
return Effect.suspend(() => {
|
|
42
|
+
if (!stored?.models.length) return Effect.succeed(undefined);
|
|
43
|
+
const entries = storedConfigs(stored, credential);
|
|
44
|
+
if (!entries) return Effect.succeed(undefined);
|
|
45
|
+
const isSameSession = entries.scope === catalogScope(credential);
|
|
46
|
+
// Another session's catalog is used only for the same verified account; account changes and logout never expose it.
|
|
47
|
+
if (!isSameSession && (account === undefined || entries.account !== account)) return Effect.succeed(undefined);
|
|
48
|
+
return restoreModelVariants(entries.configs).pipe(
|
|
49
|
+
Effect.map((projection): RestoredCatalog | undefined => ({ projection, account: account ?? entries.account as string | undefined, isSameSession })),
|
|
50
|
+
Effect.orElseSucceed(() => undefined),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Credential as HostCredential, ModelsPublication, RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
import { abortWith } from "../../../core/cancellation.ts";
|
|
4
|
+
import { toDevinFailure } from "../../../core/errors.ts";
|
|
5
|
+
import { modelIDForUID, projectModelVariants, resolveBackendSelection, type VariantProjection } from "../../../core/models/variants.ts";
|
|
6
|
+
import { endpoint } from "../../../core/protocol/endpoint.ts";
|
|
7
|
+
import type { Credential } from "../../../core/types.ts";
|
|
8
|
+
import { failWith } from "../../../core/validation.ts";
|
|
9
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
10
|
+
import { Accounts } from "../accounts/service.ts";
|
|
11
|
+
import { CredentialGuard } from "../auth/guard.ts";
|
|
12
|
+
import { AuthState } from "../auth/state.ts";
|
|
13
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
14
|
+
import { DisplaySettings, FusionSettings } from "../settings/service.ts";
|
|
15
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
16
|
+
import { DEVIN_API, isFusionModelID } from "./constants.ts";
|
|
17
|
+
import { fusionChoices } from "./fusion.ts";
|
|
18
|
+
import { isUsable, publicModelIDs } from "./ids.ts";
|
|
19
|
+
import { catalogScope, modelsFromProjection } from "./projection.ts";
|
|
20
|
+
import { RemoteCatalog } from "./remote.ts";
|
|
21
|
+
import { restoredCatalog } from "./restore.ts";
|
|
22
|
+
import type { DevinModel, ModelCatalogShape } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
const FRESHNESS_MS = 5 * 60_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The only owner of the catalog query: when to look it up, what to restore from Pi's model store, and the list Pi shows.
|
|
28
|
+
* State changes only inside a publication Pi accepted, so a cancelled or rejected refresh never changes the list.
|
|
29
|
+
*/
|
|
30
|
+
export class ModelCatalog extends Context.Service<ModelCatalog, ModelCatalogShape>()("pi-devin/ModelCatalog") {}
|
|
31
|
+
|
|
32
|
+
const publish = (context: RefreshModelsContext, publication: ModelsPublication) =>
|
|
33
|
+
Effect.tryPromise({ try: () => context.publish(publication), catch: toDevinFailure });
|
|
34
|
+
|
|
35
|
+
export const ModelCatalogLive = Layer.effect(ModelCatalog, Effect.gen(function* () {
|
|
36
|
+
const config = yield* ExtensionConfig;
|
|
37
|
+
const auth = yield* AuthState;
|
|
38
|
+
const guard = yield* CredentialGuard;
|
|
39
|
+
const accounts = yield* Accounts;
|
|
40
|
+
const remote = yield* RemoteCatalog;
|
|
41
|
+
const fusionSettings = yield* FusionSettings;
|
|
42
|
+
const displaySettings = yield* DisplaySettings;
|
|
43
|
+
const changes = yield* UsageChanges;
|
|
44
|
+
const lifetime = yield* ProviderLifetime;
|
|
45
|
+
const now = config.now;
|
|
46
|
+
|
|
47
|
+
// The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
|
|
48
|
+
// without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
|
|
49
|
+
const build = (next: VariantProjection, credential: Credential, account: string | undefined, isForStorage = false) => Effect.gen(function* () {
|
|
50
|
+
const fusion = config.isFusionChild ? false : yield* fusionSettings.read;
|
|
51
|
+
const recommendedOnly = !isForStorage && !config.isFusionChild && (yield* displaySettings.read).recommendedOnly;
|
|
52
|
+
return modelsFromProjection(next, credential, { fusion, recommendedOnly, listsAllExactModels: !isForStorage && config.isFusionChild, account });
|
|
53
|
+
});
|
|
54
|
+
let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined, catalogAccount: string | undefined;
|
|
55
|
+
let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined, credentialScope: string | undefined;
|
|
56
|
+
const clear = () => {
|
|
57
|
+
models = []; projection = undefined; scope = undefined; catalogAccount = undefined; checkedAtMs = 0; modelCredential = undefined; credentialScope = undefined;
|
|
58
|
+
};
|
|
59
|
+
const currentProjection = (credential: Credential) => !projection || scope !== catalogScope(credential)
|
|
60
|
+
? failWith("No Devin model list matches the current account and endpoint. Refresh the model list.") : Effect.succeed(projection);
|
|
61
|
+
const fromHost = (credential: HostCredential | undefined) => credential?.type === "oauth" ? auth.fromHost(credential) : Effect.succeed(undefined);
|
|
62
|
+
const effectiveCredential = (credential: HostCredential | undefined, baseUrl?: string) => Effect.flatMap(fromHost(credential), (resolved) => resolved
|
|
63
|
+
? Effect.map(endpoint(baseUrl ?? resolved.apiServerURL), (apiServerURL): Credential | undefined => ({ ...resolved, apiServerURL })) : Effect.succeed(undefined));
|
|
64
|
+
const accountOf = (credential: Credential) => Effect.map(accounts.get(credential), (record) => record?.ownerHash);
|
|
65
|
+
|
|
66
|
+
/** Only a confirmed 401 of the same sign-in generation invalidates the credential. */
|
|
67
|
+
const fetchCatalog = (credential: Credential) => Effect.gen(function* () {
|
|
68
|
+
const generation = yield* auth.generation;
|
|
69
|
+
yield* auth.assertUsable(credential);
|
|
70
|
+
return yield* remote.fetch(credential).pipe(Effect.tapError((error) => guard.reportFailure(credential, error, generation)));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const refresh = (context: RefreshModelsContext) => Effect.gen(function* () {
|
|
74
|
+
const credential = yield* effectiveCredential(context.credential, context.baseUrl);
|
|
75
|
+
if (!credential) {
|
|
76
|
+
yield* publish(context, { update: clear });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const expectedScope = catalogScope(credential);
|
|
80
|
+
// A bad cache is never used as run data, whatever is wrong with it. The current good state is kept.
|
|
81
|
+
const restored = yield* restoredCatalog(context.stored, credential, yield* accountOf(credential)).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
|
|
82
|
+
if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
|
|
83
|
+
const { projection: restoredProjection, account: restoredAccount, isSameSession } = restored;
|
|
84
|
+
const restoredModels = yield* build(restoredProjection, credential, restoredAccount);
|
|
85
|
+
const checkedAt = context.stored?.checkedAt;
|
|
86
|
+
const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
|
|
87
|
+
const accepted = yield* publish(context, {
|
|
88
|
+
// A catalog reused for a new session of the same account is stored under the new session, so a restart restores it too.
|
|
89
|
+
...(isSameSession ? {} : { persist: { models: yield* build(restoredProjection, credential, restoredAccount, true), checkedAt: restoredAtMs } }),
|
|
90
|
+
update: () => {
|
|
91
|
+
projection = restoredProjection; models = restoredModels; scope = expectedScope; catalogAccount = restoredAccount; modelCredential = credential;
|
|
92
|
+
checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
if (!accepted) return;
|
|
96
|
+
} else if (scope !== expectedScope || endpointOverride !== context.baseUrl || credentialScope !== expectedScope) {
|
|
97
|
+
const accepted = yield* publish(context, { update: () => {
|
|
98
|
+
if (scope !== expectedScope || endpointOverride !== context.baseUrl) clear();
|
|
99
|
+
endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
100
|
+
} });
|
|
101
|
+
if (!accepted) return;
|
|
102
|
+
}
|
|
103
|
+
if (!context.allowNetwork) return;
|
|
104
|
+
const ageMs = now() - checkedAtMs;
|
|
105
|
+
if (!context.force && scope === expectedScope && projection && checkedAtMs > 0 && ageMs >= 0 && ageMs < FRESHNESS_MS) return;
|
|
106
|
+
const catalog = yield* fetchCatalog(credential);
|
|
107
|
+
const nextProjection = yield* projectModelVariants(catalog);
|
|
108
|
+
// Known after an account check in this process, or carried over from this same session's catalog.
|
|
109
|
+
const nextAccount = (yield* accountOf(credential)) ?? (scope === expectedScope ? catalogAccount : undefined);
|
|
110
|
+
const nextModels = yield* build(nextProjection, credential, nextAccount);
|
|
111
|
+
const checkedAt = now();
|
|
112
|
+
if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) return yield* failWith("Invalid model cache check time.");
|
|
113
|
+
yield* publish(context, {
|
|
114
|
+
persist: { models: yield* build(nextProjection, credential, nextAccount, true), checkedAt },
|
|
115
|
+
update: () => {
|
|
116
|
+
projection = nextProjection; models = nextModels; scope = expectedScope; catalogAccount = nextAccount; modelCredential = credential;
|
|
117
|
+
checkedAtMs = checkedAt; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
models: Effect.sync(() => models),
|
|
124
|
+
unsupported: Effect.sync(() => projection?.unsupported ?? []),
|
|
125
|
+
exactLevel: (uid) => Effect.sync(() => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level),
|
|
126
|
+
fusionChoices: Effect.sync(() => projection && !config.isFusionChild ? fusionChoices(projection) : undefined),
|
|
127
|
+
rebuild: Effect.suspend(() => projection && modelCredential
|
|
128
|
+
? Effect.map(build(projection, modelCredential, catalogAccount), (next) => { models = next; }) : Effect.void),
|
|
129
|
+
currentScope: Effect.sync(() => credentialScope),
|
|
130
|
+
invalidate: Effect.sync(clear),
|
|
131
|
+
filter: (candidates, credential) => Effect.map(effectiveCredential(credential, endpointOverride), (resolved) => {
|
|
132
|
+
if (!resolved) return [];
|
|
133
|
+
const expected = catalogScope(resolved);
|
|
134
|
+
return candidates.filter((model) => model.provider === "devin" && model.api === DEVIN_API && model.baseUrl === resolved.apiServerURL && (model as DevinModel).devin?.scope === expected);
|
|
135
|
+
}).pipe(Effect.catchCause(() => Effect.succeed([]))),
|
|
136
|
+
// Sign-in, logout and restores change the current credential, which decides whose quota is shown.
|
|
137
|
+
refresh: (context) => Effect.flatMap(lifetime.current, (current) => current.guard(refresh(context)).pipe(abortWith(context.signal), Effect.ensuring(changes.publish))),
|
|
138
|
+
resolve: (modelID, level, credential) => Effect.gen(function* () {
|
|
139
|
+
const current = yield* currentProjection(credential);
|
|
140
|
+
let wanted = modelID;
|
|
141
|
+
if (isFusionModelID(modelID)) {
|
|
142
|
+
// Fusion lead requests use the same UID and thinking level as the selected lead group.
|
|
143
|
+
const fusion = models.find((model) => model.id === modelID)?.devin.fusion;
|
|
144
|
+
if (!fusion) return yield* failWith(`Fusion model unavailable: ${modelID}`);
|
|
145
|
+
wanted = fusion.leadID;
|
|
146
|
+
}
|
|
147
|
+
const target = publicModelIDs(current).targets.get(wanted);
|
|
148
|
+
if (!target) return yield* failWith(`Model or thinking level unavailable: ${modelID} (${level})`);
|
|
149
|
+
return yield* resolveBackendSelection(current, "group" in target ? target.group.id : modelIDForUID(target.variant.model.id), level);
|
|
150
|
+
}),
|
|
151
|
+
resolveUID: (uid, credential) => Effect.gen(function* () {
|
|
152
|
+
const current = yield* currentProjection(credential);
|
|
153
|
+
const level = current.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level;
|
|
154
|
+
if (!level) return yield* failWith(`Model UID unavailable: ${uid}`);
|
|
155
|
+
return yield* resolveBackendSelection(current, modelIDForUID(uid), level);
|
|
156
|
+
}),
|
|
157
|
+
} satisfies ModelCatalogShape;
|
|
158
|
+
}));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Credential as HostCredential, Model, RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { DevinError, DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
import type { ModelCatalog as Catalog } from "../../../core/models/catalog.ts";
|
|
5
|
+
import type { BackendModelSelection, EffectiveThinkingLevel } from "../../../core/models/variants.ts";
|
|
6
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
7
|
+
|
|
8
|
+
/** The Pi model API this provider registers. Duplicated as a literal type so type modules stay free of runtime imports. */
|
|
9
|
+
export type DevinAPI = "devin-connect";
|
|
10
|
+
|
|
11
|
+
/** The lead is a published group model ID; the sidekick is an exact variant model ID. Both come from `publicModelIDs`. */
|
|
12
|
+
export interface FusionSelection { leadID: string; sidekickID: string }
|
|
13
|
+
export interface FusionChoices {
|
|
14
|
+
leads: { id: string; name: string; recommended?: true }[];
|
|
15
|
+
/** `level` is the thinking level of this UID. It is passed to the sidekick child Pi as is. */
|
|
16
|
+
sidekicks: { id: string; name: string; groupName: string; level: EffectiveThinkingLevel; recommended?: true }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DevinModel extends Model<DevinAPI> {
|
|
20
|
+
devin: {
|
|
21
|
+
version: number; scope: string; costsUnknown: true; configs: JsonObject[]; unavailable?: JsonObject[];
|
|
22
|
+
fusion?: FusionSelection;
|
|
23
|
+
/** Marked as recommended by the catalog. For a group, true when any variant is recommended. */
|
|
24
|
+
recommended?: true;
|
|
25
|
+
/** Account identity hash (endpoint and user ID) the catalog belongs to. Lets a new session of the same account reuse it. */
|
|
26
|
+
account?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One remote catalog lookup for a credential, with the team's model policy applied. */
|
|
31
|
+
export interface RemoteCatalogShape {
|
|
32
|
+
readonly fetch: (credential: Credential) => Effect.Effect<Catalog, DevinFailure>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ModelCatalogShape {
|
|
36
|
+
/** The current Pi model list. Makes no request. */
|
|
37
|
+
readonly models: Effect.Effect<readonly DevinModel[]>;
|
|
38
|
+
/** The given Pi models that belong to the credential's catalog scope. Never fails. */
|
|
39
|
+
readonly filter: (models: readonly Model<DevinAPI>[], credential: HostCredential | undefined) => Effect.Effect<readonly Model<DevinAPI>[]>;
|
|
40
|
+
/** Restores or refreshes the list for Pi's current credential. Stops with the provider lifetime and Pi's signal. */
|
|
41
|
+
readonly refresh: (context: RefreshModelsContext) => Effect.Effect<void, DevinFailure>;
|
|
42
|
+
readonly resolve: (modelID: string, level: EffectiveThinkingLevel, credential: Credential) => Effect.Effect<BackendModelSelection, DevinError>;
|
|
43
|
+
/** Resolves a catalog UID chosen by a payload hook at that variant's thinking level, including UIDs hidden from the list. */
|
|
44
|
+
readonly resolveUID: (uid: string, credential: Credential) => Effect.Effect<BackendModelSelection, DevinError>;
|
|
45
|
+
readonly unsupported: Effect.Effect<readonly { uid: string; reason: string }[]>;
|
|
46
|
+
/** Thinking level of a usable exact UID in the current catalog, including hidden ones. */
|
|
47
|
+
readonly exactLevel: (uid: string) => Effect.Effect<EffectiveThinkingLevel | undefined>;
|
|
48
|
+
/** Fusion candidates of the current catalog. Undefined without a catalog or in the sidekick child. */
|
|
49
|
+
readonly fusionChoices: Effect.Effect<FusionChoices | undefined>;
|
|
50
|
+
/** Rebuilds the list from the current catalog without network after a saved choice or setting changes. */
|
|
51
|
+
readonly rebuild: Effect.Effect<void>;
|
|
52
|
+
/** Scope of the credential seen by the last refresh, even before its catalog loads. Undefined after logout or sign-in. */
|
|
53
|
+
readonly currentScope: Effect.Effect<string | undefined>;
|
|
54
|
+
/** Forgets the list, for example after a new sign-in. */
|
|
55
|
+
readonly invalidate: Effect.Effect<void>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { makeDevinClient, type DevinClient } from "../../../core/client.ts";
|
|
3
|
+
import { makeFingerprintProvider, type FingerprintProvider } from "../../../core/protocol/fingerprint.ts";
|
|
4
|
+
import { DeviceIdentitySource } from "../../../core/services/device-identity.ts";
|
|
5
|
+
import { fetchTransport, HttpTransport } from "../../../core/services/http-transport.ts";
|
|
6
|
+
import type { Credential } from "../../../core/types.ts";
|
|
7
|
+
import { evictOldest } from "../bounded.ts";
|
|
8
|
+
import { catalogScope } from "../catalog/projection.ts";
|
|
9
|
+
import type { DevinClientsShape } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Clients of one provider. A credential's device fingerprint is collected once and shared by its clients;
|
|
13
|
+
* every open client is tracked so shutdown can stop in-flight requests. Each client still has its own connection pool.
|
|
14
|
+
*/
|
|
15
|
+
export class DevinClients extends Context.Service<DevinClients, DevinClientsShape>()("pi-devin/DevinClients") {}
|
|
16
|
+
|
|
17
|
+
export const DevinClientsLive = Layer.effect(DevinClients, Effect.gen(function* () {
|
|
18
|
+
const transport = yield* HttpTransport;
|
|
19
|
+
const device = yield* DeviceIdentitySource;
|
|
20
|
+
const fingerprints = new Map<string, FingerprintProvider>();
|
|
21
|
+
const clients = new Set<DevinClient>();
|
|
22
|
+
|
|
23
|
+
const fingerprintFor = (credential: Credential) => Effect.suspend(() => {
|
|
24
|
+
const key = catalogScope(credential);
|
|
25
|
+
const cached = fingerprints.get(key);
|
|
26
|
+
if (cached) return Effect.succeed(cached);
|
|
27
|
+
evictOldest(fingerprints, 16);
|
|
28
|
+
return Effect.tap(makeFingerprintProvider(credential.sessionToken, { collectIdentity: device.collect }), (created) => Effect.sync(() => {
|
|
29
|
+
fingerprints.set(key, created);
|
|
30
|
+
}));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
open: (credential, options = {}) => Effect.gen(function* () {
|
|
35
|
+
const { fetch, ...clientOptions } = options;
|
|
36
|
+
const client = yield* makeDevinClient(credential, { ...clientOptions, fingerprint: yield* fingerprintFor(credential) }).pipe(
|
|
37
|
+
Effect.provideService(HttpTransport, fetch ? fetchTransport(fetch) : transport),
|
|
38
|
+
Effect.provideService(DeviceIdentitySource, device),
|
|
39
|
+
);
|
|
40
|
+
clients.add(client);
|
|
41
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => { clients.delete(client); }));
|
|
42
|
+
return client;
|
|
43
|
+
}),
|
|
44
|
+
closeAll: Effect.suspend(() => Effect.forEach([...clients], (client) => client.close, { discard: true })),
|
|
45
|
+
clearFingerprints: Effect.sync(() => fingerprints.clear()),
|
|
46
|
+
} satisfies DevinClientsShape;
|
|
47
|
+
}));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Effect, Scope } from "effect";
|
|
2
|
+
import type { DevinClient, MetadataProfile } from "../../../core/client.ts";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import type { ConnectClientOptions } from "../../../core/protocol/connect-client.ts";
|
|
5
|
+
import type { Credential } from "../../../core/types.ts";
|
|
6
|
+
|
|
7
|
+
export interface ClientOptions {
|
|
8
|
+
profile?: MetadataProfile;
|
|
9
|
+
/** Pi's per-request fetch. Replaces the provider's transport for this client only. */
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
onRequest?: ConnectClientOptions["onRequest"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DevinClientsShape {
|
|
16
|
+
/** A client for the calling scope. Shares the credential's device fingerprint and closes with the scope. */
|
|
17
|
+
readonly open: (credential: Credential, options?: ClientOptions) => Effect.Effect<DevinClient, DevinError, Scope.Scope>;
|
|
18
|
+
/** Closes every open client; their in-flight requests fail as closed. */
|
|
19
|
+
readonly closeAll: Effect.Effect<void>;
|
|
20
|
+
/** Forgets the per-credential fingerprints, so later clients collect device values again. */
|
|
21
|
+
readonly clearFingerprints: Effect.Effect<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Context, Effect, Scheduler } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Effect's process-wide default scheduler, the one `Effect.runFork` and `runPromise` use. */
|
|
4
|
+
const defaultScheduler = Context.get(Context.empty(), Scheduler.Scheduler);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs work that outlives the Pi call that started it on Effect's default scheduler. A fiber forked inside `runSync`, or by a
|
|
8
|
+
* runner captured while `runSync` built the layers, inherits that call's synchronous scheduler, which yields through microtasks:
|
|
9
|
+
* a long burst would then keep timers, I/O and user input waiting until it ends, instead of yielding to them every 2048 operations.
|
|
10
|
+
*/
|
|
11
|
+
export const onDefaultScheduler = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
12
|
+
Effect.provideService(effect, Scheduler.Scheduler, defaultScheduler);
|