@buyong/pi-devin 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +222 -0
- package/README.md +37 -41
- package/index.ts +6 -12
- package/package.json +4 -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 -145
- package/src/pi/devin-command.ts +0 -76
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -80
- 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 -379
- package/src/pi/provider.ts +0 -249
- 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
|
@@ -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);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { clampThinkingLevel, type AssistantMessageEventStream, type Model, type SimpleStreamOptions, type TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Cause, Effect, Stream } from "effect";
|
|
3
|
+
import { assertCanInfer } from "../../../core/auth/access.ts";
|
|
4
|
+
import { abortWith } from "../../../core/cancellation.ts";
|
|
5
|
+
import { Cancelled, isTransientConnectionFailure, RPCError, toDevinFailure, type DevinFailure } from "../../../core/errors.ts";
|
|
6
|
+
import { ChatAccumulator } from "../../../core/inference/accumulator.ts";
|
|
7
|
+
import { chatRequest } from "../../../core/inference/request.ts";
|
|
8
|
+
import type { BackendModelSelection, EffectiveThinkingLevel } from "../../../core/models/variants.ts";
|
|
9
|
+
import { endpoint } from "../../../core/protocol/endpoint.ts";
|
|
10
|
+
import { CHAT_METHOD } from "../../../core/protocol/protobuf.ts";
|
|
11
|
+
import { safeError } from "../../../core/redaction.ts";
|
|
12
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
13
|
+
import { failWith, object, requiredString, tokenCount } from "../../../core/validation.ts";
|
|
14
|
+
import { DevinResponseStream } from "../../adapters/pi/response-stream.ts";
|
|
15
|
+
import { toDevinTranscript } from "../../adapters/pi/transcript.ts";
|
|
16
|
+
import type { AccountsShape } from "../accounts/types.ts";
|
|
17
|
+
import type { AuthStateShape, CredentialGuardShape } from "../auth/types.ts";
|
|
18
|
+
import { isFusionModelID } from "../catalog/constants.ts";
|
|
19
|
+
import { hasExtendedPromptCacheRetention } from "../catalog/projection.ts";
|
|
20
|
+
import type { DevinAPI, ModelCatalogShape } from "../catalog/types.ts";
|
|
21
|
+
import type { DevinClientsShape } from "../clients/types.ts";
|
|
22
|
+
import type { ProviderLifetimeShape } from "../lifetime/types.ts";
|
|
23
|
+
import type { DevinLifecycle } from "../runs/lifecycle.ts";
|
|
24
|
+
import { FUSION_LEAD_GUIDANCE } from "../sidekick/prompts.ts";
|
|
25
|
+
|
|
26
|
+
/** Services one request uses, resolved once when the inference service is built. */
|
|
27
|
+
export interface InferenceDependencies {
|
|
28
|
+
auth: AuthStateShape;
|
|
29
|
+
guard: CredentialGuardShape;
|
|
30
|
+
catalog: ModelCatalogShape;
|
|
31
|
+
accounts: AccountsShape;
|
|
32
|
+
clients: DevinClientsShape;
|
|
33
|
+
runs: DevinLifecycle;
|
|
34
|
+
lifetime: ProviderLifetimeShape;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One provider call: Pi's event stream, returned at once, and the request that feeds it. */
|
|
38
|
+
export interface InferenceRequest {
|
|
39
|
+
stream: AssistantMessageEventStream;
|
|
40
|
+
/** Never fails: every ending closes the stream with exactly one terminal event. */
|
|
41
|
+
run: Effect.Effect<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Connect codes of passing server trouble, in the words Pi's host retry matches. */
|
|
45
|
+
const CONNECT_RETRY_LABELS: Readonly<Record<string, string>> = {
|
|
46
|
+
resource_exhausted: "Rate limit", internal: "Internal error", deadline_exceeded: "Request timed out",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pi decides host retries from the message text alone. Node and Connect report failures as codes ("read ECONNRESET",
|
|
51
|
+
* "resource_exhausted: …") and an error body replaces the HTTP status, so the status or code is kept in words Pi matches.
|
|
52
|
+
* The original detail stays, so Pi's rules against retrying quota and billing errors still apply.
|
|
53
|
+
*/
|
|
54
|
+
function hostRetryableMessage(error: unknown, message: string): string {
|
|
55
|
+
if (isTransientConnectionFailure(error)) return `Connection error (${message})`;
|
|
56
|
+
if (!(error instanceof RPCError)) return message;
|
|
57
|
+
if (error.httpStatus !== undefined && error.httpStatus >= 400) return message.includes(String(error.httpStatus)) ? message : `HTTP ${error.httpStatus} (${message})`;
|
|
58
|
+
const label = CONNECT_RETRY_LABELS[error.code];
|
|
59
|
+
return label ? `${label} (${message})` : message;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Like Pi's built-in providers: the caller option wins; otherwise only PI_CACHE_RETENTION=long (provider env first) means long retention. */
|
|
63
|
+
function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
|
|
64
|
+
if (options.cacheRetention) return options.cacheRetention;
|
|
65
|
+
return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A host hook may return a value or a promise and may throw; it is withdrawn when the request stops. */
|
|
69
|
+
const hook = <A>(evaluate: () => A | PromiseLike<A>) => Effect.tryPromise({ try: async () => await evaluate(), catch: toDevinFailure });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prepares one request synchronously: the response stream (with its start event), the sign-in generation
|
|
73
|
+
* and the provider lifetime current at the call. The returned `run` sends the request and reports into the stream.
|
|
74
|
+
*/
|
|
75
|
+
export const prepareRequest = (deps: InferenceDependencies, model: Model<DevinAPI>, context: TranscriptContext, requestOptions: SimpleStreamOptions = {}): Effect.Effect<InferenceRequest> => Effect.gen(function* () {
|
|
76
|
+
const effectiveLevel = (requestOptions.reasoning ?? clampThinkingLevel(model, "off")) as EffectiveThinkingLevel;
|
|
77
|
+
const response = new DevinResponseStream(model, "");
|
|
78
|
+
const accumulator = new ChatAccumulator();
|
|
79
|
+
const generation = yield* deps.auth.generation;
|
|
80
|
+
const lifetime = yield* deps.lifetime.current;
|
|
81
|
+
let credential: Credential | undefined, actualUID = "", isRedacted = false;
|
|
82
|
+
|
|
83
|
+
const run = Effect.gen(function* () {
|
|
84
|
+
const current: Credential = {
|
|
85
|
+
version: 1, sessionToken: yield* requiredString(requestOptions.apiKey, "Devin account authentication (/login required)"),
|
|
86
|
+
apiServerURL: yield* endpoint(model.baseUrl), createdAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
credential = current;
|
|
89
|
+
// Pi can override the OAuth value with an apiKey in models.json or a caller option. Only OAuth sessions are accepted.
|
|
90
|
+
yield* deps.auth.assertOAuthSession(current);
|
|
91
|
+
yield* deps.auth.assertUsable(current);
|
|
92
|
+
let selected = yield* deps.catalog.resolve(model.id, effectiveLevel, current);
|
|
93
|
+
actualUID = selected.uid;
|
|
94
|
+
response.message.providerThinkingLevel = actualUID;
|
|
95
|
+
// Retries belong to Pi and the only transport is a Connect stream, so `maxRetries` and `transport` preferences are ignored.
|
|
96
|
+
const cacheRetention = resolveCacheRetention(requestOptions);
|
|
97
|
+
const assertCacheRetention = (selection: BackendModelSelection) => cacheRetention === "long" && !hasExtendedPromptCacheRetention(selection.variant)
|
|
98
|
+
? failWith("Long cache lifetime is not confirmed for this Devin model. Long retention is supported only for models whose catalog Prompt Cache Retention is 24h.")
|
|
99
|
+
: Effect.void;
|
|
100
|
+
yield* assertCacheRetention(selected);
|
|
101
|
+
|
|
102
|
+
// The per-request account check (permission and quota) runs here, outside Pi's shared credential lock.
|
|
103
|
+
const known = yield* deps.accounts.get(current);
|
|
104
|
+
yield* deps.accounts.check(current, {
|
|
105
|
+
expectedGeneration: generation,
|
|
106
|
+
connection: { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs },
|
|
107
|
+
beforeRecord: deps.auth.assertUsable(current),
|
|
108
|
+
}).pipe(
|
|
109
|
+
Effect.flatMap((outcome) => outcome === "stale" ? Effect.fail(new Cancelled({ message: "The Devin authentication generation changed." })) : Effect.void),
|
|
110
|
+
// A check that cannot reach the server keeps the last known account; the model request reports its own failure.
|
|
111
|
+
Effect.catch((error) => known && isTransientConnectionFailure(error) ? Effect.void : Effect.fail(error)),
|
|
112
|
+
);
|
|
113
|
+
const account = yield* deps.accounts.get(current);
|
|
114
|
+
if (!account) return yield* failWith("The Devin account check was interrupted.");
|
|
115
|
+
yield* assertCanInfer(account.status);
|
|
116
|
+
|
|
117
|
+
const lease = yield* Effect.acquireRelease(deps.runs.acquire(account.ownerHash), (acquired) => acquired.release);
|
|
118
|
+
const transcript = yield* toDevinTranscript(context, selected.model);
|
|
119
|
+
if (isFusionModelID(model.id)) transcript.systemPrompt = [transcript.systemPrompt, FUSION_LEAD_GUIDANCE].filter(Boolean).join("\n\n");
|
|
120
|
+
const maximum = requestOptions.maxTokens === undefined ? selected.model.maxOutputTokens : yield* tokenCount(requestOptions.maxTokens, "maxTokens");
|
|
121
|
+
if (maximum < 1 || maximum > selected.model.maxOutputTokens) return yield* failWith("The output limit is outside the selected Devin model range.");
|
|
122
|
+
const client = yield* deps.clients.open(current, { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs,
|
|
123
|
+
onRequest: (attempt) => attempt.isMetered ? lease.assertAttempt : Effect.void,
|
|
124
|
+
});
|
|
125
|
+
yield* lease.addCleanup(client.close);
|
|
126
|
+
const request = chatRequest({ metadata: yield* client.metadata(), model: selected.uid, ...transcript, maxTokens: maximum,
|
|
127
|
+
cascadeId: lease.cascadeId, trajectoryId: lease.trajectoryId, stepIndex: lease.stepIndex,
|
|
128
|
+
executionId: lease.executionId, clientSessionId: requestOptions.sessionId ?? lease.sessionId,
|
|
129
|
+
isAuxiliary: lease.isAuxiliary, isRecovery: lease.isRecovery, includeContext: false,
|
|
130
|
+
});
|
|
131
|
+
if (requestOptions.temperature !== undefined) {
|
|
132
|
+
if (!Number.isFinite(requestOptions.temperature) || requestOptions.temperature < 0) return yield* failWith("temperature must be a finite value of 0 or more.");
|
|
133
|
+
(yield* object(request.configuration, "configuration")).temperature = requestOptions.temperature;
|
|
134
|
+
}
|
|
135
|
+
if (cacheRetention === "none") for (const prompt of request.chatMessagePrompts as JsonObject[]) delete prompt.promptCacheOptions;
|
|
136
|
+
const identity = JSON.stringify([request.cascadeId, request.trajectoryReference, request.executionId, request.clientSessionId]);
|
|
137
|
+
|
|
138
|
+
const frames = client.transport.stream(CHAT_METHOD, request, {
|
|
139
|
+
headers: requestOptions.headers,
|
|
140
|
+
onPayload: (payload) => Effect.gen(function* () {
|
|
141
|
+
const replacement = requestOptions.onPayload ? yield* hook(() => requestOptions.onPayload!(payload, model)) : undefined;
|
|
142
|
+
const finalPayload = replacement === undefined ? payload : yield* object(replacement, "provider payload");
|
|
143
|
+
if (JSON.stringify([finalPayload.cascadeId, finalPayload.trajectoryReference, finalPayload.executionId, finalPayload.clientSessionId]) !== identity) {
|
|
144
|
+
return yield* failWith("A payload hook cannot change the owned run, session or step identifiers.");
|
|
145
|
+
}
|
|
146
|
+
actualUID = yield* requiredString(finalPayload.chatModelUid, "chatModelUid");
|
|
147
|
+
if (actualUID !== selected.uid) {
|
|
148
|
+
// An exact UID hidden from the list is re-resolved at that UID's level when the current catalog has it.
|
|
149
|
+
if (!(yield* deps.catalog.exactLevel(actualUID))) return yield* failWith("A payload hook selected an unavailable model UID.");
|
|
150
|
+
selected = yield* deps.catalog.resolveUID(actualUID, current);
|
|
151
|
+
yield* toDevinTranscript(context, selected.model); // A model change re-checks existing image and tool limits.
|
|
152
|
+
yield* assertCacheRetention(selected);
|
|
153
|
+
}
|
|
154
|
+
const outputLimit = yield* tokenCount((yield* object(finalPayload.configuration, "configuration")).maxTokens, "maxTokens");
|
|
155
|
+
if (outputLimit < 1 || outputLimit > selected.model.maxOutputTokens) return yield* failWith("The payload hook output limit is outside the model range.");
|
|
156
|
+
response.message.providerThinkingLevel = actualUID;
|
|
157
|
+
return finalPayload;
|
|
158
|
+
}),
|
|
159
|
+
onResponse: (info) => Effect.gen(function* () {
|
|
160
|
+
if (info.status === 401) yield* deps.guard.reportFailure(current, RPCError.make("unauthenticated", "HTTP 401", 401), generation);
|
|
161
|
+
if (requestOptions.onResponse) yield* hook(() => requestOptions.onResponse!(info, model));
|
|
162
|
+
}),
|
|
163
|
+
onFrame: (frame) => Effect.gen(function* () {
|
|
164
|
+
if (requestOptions.onProviderStreamEvent) yield* hook(() => requestOptions.onProviderStreamEvent!(structuredClone(frame.data), model));
|
|
165
|
+
if (frame.isEnd && frame.data.error) return; // Connect error classification belongs to the transport.
|
|
166
|
+
if (frame.data.redact === true) { isRedacted = true; yield* response.redact; }
|
|
167
|
+
yield* accumulator.accept(frame.data, frame.isEnd);
|
|
168
|
+
if (!frame.isEnd) yield* response.accept(frame.data, accumulator);
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
yield* lease.cancellation.guard(Stream.runDrain(frames));
|
|
172
|
+
const result = yield* accumulator.finish;
|
|
173
|
+
result.message.modelUid = actualUID;
|
|
174
|
+
yield* response.finish(result);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The scope encloses the failure report as well, so Pi sees the terminal event before the client closes and the lease lets the next request in.
|
|
178
|
+
const settled = Effect.scoped(lifetime.guard(run).pipe(
|
|
179
|
+
abortWith(requestOptions.signal),
|
|
180
|
+
// Every ending, including a defect, closes Pi's stream with exactly one terminal event.
|
|
181
|
+
Effect.catchCause((cause) => Effect.gen(function* () {
|
|
182
|
+
const error: DevinFailure = Cause.hasInterruptsOnly(cause) ? new Cancelled({ message: "The Devin request was interrupted." }) : toDevinFailure(Cause.squash(cause));
|
|
183
|
+
if (credential) yield* deps.guard.reportFailure(credential, error, generation);
|
|
184
|
+
accumulator.message.modelUid = actualUID || undefined;
|
|
185
|
+
yield* response.fail(hostRetryableMessage(error, safeError(error, credential ? [credential.sessionToken] : [])),
|
|
186
|
+
error.name === "AbortError", isRedacted ? undefined : accumulator.message);
|
|
187
|
+
})),
|
|
188
|
+
));
|
|
189
|
+
return { stream: response.stream, run: settled };
|
|
190
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context, Effect, FiberSet, Layer } from "effect";
|
|
2
|
+
import { Accounts } from "../accounts/service.ts";
|
|
3
|
+
import { CredentialGuard } from "../auth/guard.ts";
|
|
4
|
+
import { AuthState } from "../auth/state.ts";
|
|
5
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
6
|
+
import { DevinClients } from "../clients/service.ts";
|
|
7
|
+
import { onDefaultScheduler } from "../default-scheduler.ts";
|
|
8
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
9
|
+
import { Runs } from "../runs/service.ts";
|
|
10
|
+
import { prepareRequest, type InferenceDependencies } from "./request.ts";
|
|
11
|
+
import type { InferenceShape } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Devin requests behind Pi's provider calls. Each request owns its scope (lease, request client) and runs in a fiber of this
|
|
15
|
+
* service, so it outlives the synchronous provider call that returned its stream. It stops through its own guards, never by Pi returning.
|
|
16
|
+
*/
|
|
17
|
+
export class Inference extends Context.Service<Inference, InferenceShape>()("pi-devin/Inference") {}
|
|
18
|
+
|
|
19
|
+
export const InferenceLive = Layer.effect(Inference, Effect.gen(function* () {
|
|
20
|
+
const deps: InferenceDependencies = {
|
|
21
|
+
auth: yield* AuthState, guard: yield* CredentialGuard, catalog: yield* ModelCatalog, accounts: yield* Accounts,
|
|
22
|
+
clients: yield* DevinClients, runs: yield* Runs, lifetime: yield* ProviderLifetime,
|
|
23
|
+
};
|
|
24
|
+
const requests = yield* FiberSet.make<void, never>();
|
|
25
|
+
return {
|
|
26
|
+
start: (model, context, options) => Effect.gen(function* () {
|
|
27
|
+
const request = yield* prepareRequest(deps, model, context, options);
|
|
28
|
+
// Starts at once, as the previous synchronous prefix did: validation failures reach the stream before the call returns.
|
|
29
|
+
yield* FiberSet.run(requests, onDefaultScheduler(request.run), { startImmediately: true });
|
|
30
|
+
return request.stream;
|
|
31
|
+
}),
|
|
32
|
+
} satisfies InferenceShape;
|
|
33
|
+
}));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AssistantMessageEventStream, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { DevinAPI } from "../catalog/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface InferenceShape {
|
|
6
|
+
/**
|
|
7
|
+
* Returns Pi's event stream for one Devin request. Runs synchronously up to the first wait: the stream carries its
|
|
8
|
+
* start event, and the request already holds its sign-in generation and provider lifetime. The request runs on in a fiber the service owns.
|
|
9
|
+
*/
|
|
10
|
+
readonly start: (model: Model<DevinAPI>, context: TranscriptContext, options?: SimpleStreamOptions) => Effect.Effect<AssistantMessageEventStream>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { makeCancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { ProviderLifetimeShape } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long the provider's work may run: from a session attach until shutdown. Shutdown ends it with a reason
|
|
7
|
+
* instead of interrupting fibers, so stopped requests report that reason. The next session attach starts a new one.
|
|
8
|
+
*/
|
|
9
|
+
export class ProviderLifetime extends Context.Service<ProviderLifetime, ProviderLifetimeShape>()("pi-devin/ProviderLifetime") {}
|
|
10
|
+
|
|
11
|
+
export const ProviderLifetimeLive = Layer.sync(ProviderLifetime, () => {
|
|
12
|
+
let current = makeCancellation();
|
|
13
|
+
return {
|
|
14
|
+
current: Effect.sync(() => current),
|
|
15
|
+
isActive: Effect.sync(() => !current.isCancelled()),
|
|
16
|
+
end: (reason) => Effect.sync(() => current.cancelUnsafe(reason)),
|
|
17
|
+
renew: Effect.sync(() => { if (current.isCancelled()) current = makeCancellation(); }),
|
|
18
|
+
} satisfies ProviderLifetimeShape;
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Cancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProviderLifetimeShape {
|
|
6
|
+
/** The lifetime current at the call. Work captures it when it starts, so a later session's lifetime never stops it. */
|
|
7
|
+
readonly current: Effect.Effect<Cancellation>;
|
|
8
|
+
readonly isActive: Effect.Effect<boolean>;
|
|
9
|
+
/** Ends the current lifetime; work guarded by it fails with this reason. */
|
|
10
|
+
readonly end: (reason: DevinFailure) => Effect.Effect<void>;
|
|
11
|
+
/** Starts a new lifetime when the current one has ended. */
|
|
12
|
+
readonly renew: Effect.Effect<void>;
|
|
13
|
+
}
|