@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,69 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
3
|
+
import { requiredString } from "../../../core/validation.ts";
|
|
4
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
5
|
+
import { AuthState } from "../auth/state.ts";
|
|
6
|
+
import { evictOldest } from "../bounded.ts";
|
|
7
|
+
import { catalogScope } from "../catalog/projection.ts";
|
|
8
|
+
import { DevinClients } from "../clients/service.ts";
|
|
9
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
10
|
+
import { UsageStore } from "../settings/service.ts";
|
|
11
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
12
|
+
import { accountUsage } from "../usage/report.ts";
|
|
13
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
14
|
+
import type { AccountRecord, AccountsShape } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The only owner of the account status query (`GetUserStatus`): accounts this process verified, by credential scope,
|
|
18
|
+
* and the newest quota they reported. Request checks, the check after sign-in and the periodic session check all go through it.
|
|
19
|
+
*/
|
|
20
|
+
export class Accounts extends Context.Service<Accounts, AccountsShape>()("pi-devin/Accounts") {}
|
|
21
|
+
|
|
22
|
+
export const AccountsLive = Layer.effect(Accounts, Effect.gen(function* () {
|
|
23
|
+
const clients = yield* DevinClients;
|
|
24
|
+
const store = yield* UsageStore;
|
|
25
|
+
const changes = yield* UsageChanges;
|
|
26
|
+
const lifetime = yield* ProviderLifetime;
|
|
27
|
+
const auth = yield* AuthState;
|
|
28
|
+
const config = yield* ExtensionConfig;
|
|
29
|
+
const records = new Map<string, AccountRecord>();
|
|
30
|
+
let latestUsage: { scope: string; usage: AccountUsage } | undefined;
|
|
31
|
+
|
|
32
|
+
// A stopped provider records nothing more.
|
|
33
|
+
const record = (credential: Credential, ownerHash: string, status: JsonObject) => Effect.gen(function* () {
|
|
34
|
+
if (!(yield* lifetime.isActive)) return;
|
|
35
|
+
const scope = catalogScope(credential);
|
|
36
|
+
evictOldest(records, 16);
|
|
37
|
+
records.set(scope, { ownerHash, status });
|
|
38
|
+
const usage = accountUsage(status, config.now());
|
|
39
|
+
latestUsage = { scope, usage };
|
|
40
|
+
// Subagents and the Fusion sidekick run in other processes; a failed write only loses sharing.
|
|
41
|
+
yield* Effect.ignore(store.write(scope, usage));
|
|
42
|
+
yield* changes.publish;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
get: (credential) => Effect.sync(() => records.get(catalogScope(credential))),
|
|
47
|
+
check: (credential, check) => Effect.scoped(Effect.gen(function* () {
|
|
48
|
+
const client = yield* clients.open(credential, { profile: "login", ...check.connection });
|
|
49
|
+
const status = yield* client.status();
|
|
50
|
+
if ((yield* auth.generation) !== check.expectedGeneration) return "stale" as const;
|
|
51
|
+
if (check.beforeRecord) yield* check.beforeRecord;
|
|
52
|
+
yield* record(credential, yield* requiredString(yield* client.accountIdentityHash, "Devin account scope"), status);
|
|
53
|
+
return "recorded" as const;
|
|
54
|
+
})),
|
|
55
|
+
forget: (credential) => Effect.sync(() => {
|
|
56
|
+
records.delete(catalogScope(credential));
|
|
57
|
+
latestUsage = undefined;
|
|
58
|
+
}),
|
|
59
|
+
clear: Effect.sync(() => {
|
|
60
|
+
records.clear();
|
|
61
|
+
latestUsage = undefined;
|
|
62
|
+
}),
|
|
63
|
+
usage: (scope) => Effect.map(store.read(scope), (shared) => {
|
|
64
|
+
const local = latestUsage?.scope === scope ? latestUsage.usage : undefined;
|
|
65
|
+
const newest = shared && (!local || shared.fetchedAtMs > local.fetchedAtMs) ? shared : local;
|
|
66
|
+
return newest ? { ...newest } : undefined;
|
|
67
|
+
}),
|
|
68
|
+
} satisfies AccountsShape;
|
|
69
|
+
}));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
3
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
4
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AccountRecord { ownerHash: string; status: JsonObject }
|
|
7
|
+
|
|
8
|
+
export interface AccountCheck {
|
|
9
|
+
/** Sign-in generation the caller started in. When a newer sign-in started meanwhile, nothing is recorded. */
|
|
10
|
+
expectedGeneration: number;
|
|
11
|
+
/** Connection settings of the lookup, such as Pi's per-request fetch and time limit. */
|
|
12
|
+
connection?: { fetch?: typeof fetch; timeoutMs?: number };
|
|
13
|
+
/** Runs after the generation check and before recording. Its failure leaves the result unrecorded. */
|
|
14
|
+
beforeRecord?: Effect.Effect<void, DevinFailure>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AccountsShape {
|
|
18
|
+
readonly get: (credential: Credential) => Effect.Effect<AccountRecord | undefined>;
|
|
19
|
+
/** Looks up the account status and records it. `"stale"` when a newer sign-in started meanwhile. Failure policy belongs to the caller. */
|
|
20
|
+
readonly check: (credential: Credential, check: AccountCheck) => Effect.Effect<"recorded" | "stale", DevinFailure>;
|
|
21
|
+
readonly forget: (credential: Credential) => Effect.Effect<void>;
|
|
22
|
+
readonly clear: Effect.Effect<void>;
|
|
23
|
+
/** Newest quota for the scope, received here or by another process sharing the usage store. Makes no request. */
|
|
24
|
+
readonly usage: (scope: string) => Effect.Effect<AccountUsage | undefined>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
5
|
+
import { endpoint } from "../../../core/protocol/endpoint.ts";
|
|
6
|
+
import type { Credential } from "../../../core/types.ts";
|
|
7
|
+
import { failWith, object, requiredString } from "../../../core/validation.ts";
|
|
8
|
+
|
|
9
|
+
export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
|
|
10
|
+
/**
|
|
11
|
+
* Pi checks the stored session again after this interval. It is not a server TTL: the server expiry is unknown, no new token
|
|
12
|
+
* is issued, and each model request still checks the account outside Pi's credential lock.
|
|
13
|
+
*/
|
|
14
|
+
export const SESSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export function credentialFromOAuth(credential: OAuthCredential): Effect.Effect<Credential, DevinError> {
|
|
17
|
+
return Effect.gen(function* () {
|
|
18
|
+
// 0.1.4 and earlier stored `expires: 0`; Pi checks such a session again on its next use.
|
|
19
|
+
if (credential.type !== "oauth" || credential.refresh !== "" || !Number.isSafeInteger(credential.expires) || credential.expires < 0) {
|
|
20
|
+
return yield* failWith("Unsupported Devin account credential. Sign in again with /login.");
|
|
21
|
+
}
|
|
22
|
+
const metadata = yield* object(credential.devin, "Devin credential metadata");
|
|
23
|
+
if (metadata.version !== 1) return yield* failWith("The Devin account credential version differs. Sign in again with /login.");
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
sessionToken: yield* requiredString(credential.access, "Devin credential access"),
|
|
27
|
+
apiServerURL: yield* endpoint(yield* requiredString(metadata.apiServerURL, "Devin credential API server")),
|
|
28
|
+
createdAt: yield* requiredString(metadata.createdAt, "Devin credential creation time"),
|
|
29
|
+
} satisfies Credential;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function credentialKey(credential: Credential): string {
|
|
34
|
+
return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Endpoint mismatches are rejected separately by the model list scope. This only checks that the session came from OAuth. */
|
|
38
|
+
export function sessionKey(credential: Credential): string {
|
|
39
|
+
return createHash("sha256").update(credential.sessionToken).digest("hex");
|
|
40
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { isAuthenticationError } from "../../../core/errors.ts";
|
|
3
|
+
import { Accounts } from "../accounts/service.ts";
|
|
4
|
+
import { Runs } from "../runs/service.ts";
|
|
5
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
6
|
+
import { AuthState } from "./state.ts";
|
|
7
|
+
import type { CredentialGuardShape } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Turns a confirmed authentication failure into its consequences, in order and within the reporting effect. */
|
|
10
|
+
export class CredentialGuard extends Context.Service<CredentialGuard, CredentialGuardShape>()("pi-devin/CredentialGuard") {}
|
|
11
|
+
|
|
12
|
+
export const CredentialGuardLive = Layer.effect(CredentialGuard, Effect.gen(function* () {
|
|
13
|
+
const auth = yield* AuthState;
|
|
14
|
+
const accounts = yield* Accounts;
|
|
15
|
+
const runs = yield* Runs;
|
|
16
|
+
const changes = yield* UsageChanges;
|
|
17
|
+
return {
|
|
18
|
+
reportFailure: (credential, error, generation) => Effect.gen(function* () {
|
|
19
|
+
if (!isAuthenticationError(error) || !(yield* auth.markInvalid(credential, error, generation))) return;
|
|
20
|
+
yield* accounts.forget(credential);
|
|
21
|
+
yield* runs.abort(error);
|
|
22
|
+
yield* changes.publish;
|
|
23
|
+
}),
|
|
24
|
+
} satisfies CredentialGuardShape;
|
|
25
|
+
}));
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AuthInteraction, OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Context, Duration, Effect, Layer } from "effect";
|
|
3
|
+
import { loginAccount } from "../../../core/auth/login.ts";
|
|
4
|
+
import { abortWith } from "../../../core/cancellation.ts";
|
|
5
|
+
import { Cancelled, isTransientConnectionFailure, toDevinFailure } from "../../../core/errors.ts";
|
|
6
|
+
import { HttpTransport, type HttpTransportShape } from "../../../core/services/http-transport.ts";
|
|
7
|
+
import { failWith } from "../../../core/validation.ts";
|
|
8
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
9
|
+
import { Accounts } from "../accounts/service.ts";
|
|
10
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
11
|
+
import { DevinClients } from "../clients/service.ts";
|
|
12
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
13
|
+
import { Runs } from "../runs/service.ts";
|
|
14
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
15
|
+
import { credentialFromOAuth, SESSION_CHECK_INTERVAL_MS } from "./credential.ts";
|
|
16
|
+
import { CredentialGuard } from "./guard.ts";
|
|
17
|
+
import { AuthState } from "./state.ts";
|
|
18
|
+
import type { AccountLoginShape } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Upper bound of the account check right after sign-in. Sign-in succeeds even when this check fails. */
|
|
21
|
+
const LOGIN_CHECK_TIMEOUT_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
/** The transport of `/login` code exchanges, chosen by the environment like the provider's own transport. */
|
|
24
|
+
export class LoginTransport extends Context.Service<LoginTransport, HttpTransportShape>()("pi-devin/LoginTransport") {}
|
|
25
|
+
|
|
26
|
+
/** Pi's OAuth flows: sign-in, the periodic session check under Pi's credential lock, and request auth. */
|
|
27
|
+
export class AccountLogin extends Context.Service<AccountLogin, AccountLoginShape>()("pi-devin/AccountLogin") {}
|
|
28
|
+
|
|
29
|
+
/** A host prompt that is withdrawn, through its signal, when the waiting effect is interrupted. */
|
|
30
|
+
const prompt = <A>(request: (signal: AbortSignal) => Promise<A>) => Effect.tryPromise({ try: request, catch: toDevinFailure });
|
|
31
|
+
|
|
32
|
+
export const AccountLoginLive = Layer.effect(AccountLogin, Effect.gen(function* () {
|
|
33
|
+
const config = yield* ExtensionConfig;
|
|
34
|
+
const auth = yield* AuthState;
|
|
35
|
+
const accounts = yield* Accounts;
|
|
36
|
+
const guard = yield* CredentialGuard;
|
|
37
|
+
const catalog = yield* ModelCatalog;
|
|
38
|
+
const clients = yield* DevinClients;
|
|
39
|
+
const runs = yield* Runs;
|
|
40
|
+
const changes = yield* UsageChanges;
|
|
41
|
+
const lifetime = yield* ProviderLifetime;
|
|
42
|
+
const transport = yield* LoginTransport;
|
|
43
|
+
const now = config.now;
|
|
44
|
+
|
|
45
|
+
/** Stops the work when the caller's signal or the provider lifetime current at the start of the host call ends, failing with that reason. */
|
|
46
|
+
const stoppable = (signal: AbortSignal | undefined) => Effect.map(lifetime.current, (current) => <A, E>(effect: Effect.Effect<A, E>) =>
|
|
47
|
+
current.guard(effect).pipe(abortWith(signal)));
|
|
48
|
+
|
|
49
|
+
const signIn = (interaction: AuthInteraction) => Effect.gen(function* () {
|
|
50
|
+
const choice = yield* prompt((signal) => interaction.prompt({ type: "select", signal, message: "Devin account sign-in method", options: [
|
|
51
|
+
{ id: "browser", label: "Browser sign-in", description: "Choose an account, then return through a loopback callback." },
|
|
52
|
+
{ id: "manual", label: "Manual authorization code", description: "Enter a one-time code using the Devin CLI manual flow. This is not an API key prompt." },
|
|
53
|
+
] }));
|
|
54
|
+
if (choice !== "browser" && choice !== "manual") return yield* failWith("Unsupported Devin sign-in method.");
|
|
55
|
+
const credential = yield* loginAccount({
|
|
56
|
+
...config.login, flow: choice,
|
|
57
|
+
onURL: (url) => Effect.sync(() => interaction.notify({ type: "auth_url", url, instructions: "Return to the terminal after signing in. Signing in does not guarantee CLI access or quota." })),
|
|
58
|
+
promptCode: prompt((signal) => interaction.prompt({ type: "manual_code", message: "Paste the authorization code or callback URL. Do not enter an API key or session token.", signal })),
|
|
59
|
+
}).pipe(Effect.provideService(HttpTransport, transport));
|
|
60
|
+
return { choice, credential };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A new session replaces everything learned from the previous one, in this order, before its own account is checked. */
|
|
64
|
+
const replaceSession = Effect.all([
|
|
65
|
+
accounts.clear, clients.clearFingerprints, catalog.invalidate,
|
|
66
|
+
runs.abort(new Cancelled({ message: "The Devin account was re-authenticated." })), changes.publish,
|
|
67
|
+
], { discard: true });
|
|
68
|
+
|
|
69
|
+
const login = (interaction: AuthInteraction) => Effect.gen(function* () {
|
|
70
|
+
const stop = yield* stoppable(interaction.signal);
|
|
71
|
+
const { choice, credential } = yield* stop(signIn(interaction));
|
|
72
|
+
const generation = yield* auth.acceptLogin(credential);
|
|
73
|
+
yield* replaceSession;
|
|
74
|
+
// Records the new session's account and quota so the same account's model list and usage are available at once.
|
|
75
|
+
// Sign-in is not a permission check: a failure here, including cancellation, is left to the first model request.
|
|
76
|
+
yield* stop(accounts.check(credential, { expectedGeneration: generation })).pipe(
|
|
77
|
+
Effect.timeoutOrElse({ duration: Duration.millis(LOGIN_CHECK_TIMEOUT_MS), orElse: () => Effect.void }),
|
|
78
|
+
Effect.ignore,
|
|
79
|
+
);
|
|
80
|
+
interaction.notify({ type: "info", message: "Signed in to the Devin account. The server expiry time and refresh grant are unknown. If authentication fails, sign in again with /login." });
|
|
81
|
+
return {
|
|
82
|
+
type: "oauth", access: credential.sessionToken, refresh: "", expires: now() + SESSION_CHECK_INTERVAL_MS,
|
|
83
|
+
devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
|
|
84
|
+
} satisfies OAuthCredential;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const usableCredential = (current: OAuthCredential) => Effect.gen(function* () {
|
|
88
|
+
const credential = yield* credentialFromOAuth(current);
|
|
89
|
+
yield* auth.assertUsable(credential);
|
|
90
|
+
return yield* auth.rememberOAuth(credential);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const refresh = (current: OAuthCredential) => Effect.gen(function* () {
|
|
94
|
+
const credential = yield* usableCredential(current);
|
|
95
|
+
// Pi calls this while holding the shared auth.json lock once the check interval has passed, and saves the result.
|
|
96
|
+
// The new expiry only schedules the next check; the token is never renewed.
|
|
97
|
+
const checked = (): OAuthCredential => ({ ...current, expires: now() + SESSION_CHECK_INTERVAL_MS });
|
|
98
|
+
// Model requests already check the account outside the lock, so a process that checked it does not wait on the network.
|
|
99
|
+
if (yield* accounts.get(credential)) return checked();
|
|
100
|
+
const expectedGeneration = yield* auth.generation;
|
|
101
|
+
return yield* accounts.check(credential, { expectedGeneration }).pipe(
|
|
102
|
+
Effect.map(checked),
|
|
103
|
+
Effect.catch((error) => Effect.andThen(guard.reportFailure(credential, error, expectedGeneration),
|
|
104
|
+
// An unreachable check must not block the request: keep the stored session unchanged so the model request
|
|
105
|
+
// succeeds or reports its own retryable transport error, and the next use checks again.
|
|
106
|
+
isTransientConnectionFailure(error) ? Effect.succeed(current) : Effect.fail(error))),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
login,
|
|
112
|
+
refresh: (current, signal) => Effect.flatMap(stoppable(signal), (stop) => stop(refresh(current))),
|
|
113
|
+
// apiKey is only ModelAuth's transport field; the sign-in/storage auth type is always oauth.
|
|
114
|
+
toAuth: (current) => Effect.map(usableCredential(current), (credential) => ({ apiKey: credential.sessionToken, baseUrl: credential.apiServerURL })),
|
|
115
|
+
} satisfies AccountLoginShape;
|
|
116
|
+
}));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { isAuthenticationError, RPCError } from "../../../core/errors.ts";
|
|
3
|
+
import type { Credential } from "../../../core/types.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import { evictOldest } from "../bounded.ts";
|
|
6
|
+
import { credentialFromOAuth, credentialKey, OAUTH_ONLY_MESSAGE, sessionKey } from "./credential.ts";
|
|
7
|
+
import type { AuthStateShape } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a credential may be used: the sign-in generation, credentials invalidated by a confirmed 401,
|
|
11
|
+
* and the OAuth sessions this process has seen. Remembers only hashes, never a token.
|
|
12
|
+
*/
|
|
13
|
+
export class AuthState extends Context.Service<AuthState, AuthStateShape>()("pi-devin/AuthState") {}
|
|
14
|
+
|
|
15
|
+
export const AuthStateLive = Layer.sync(AuthState, () => {
|
|
16
|
+
const invalid = new Set<string>();
|
|
17
|
+
const oauthSessions = new Set<string>();
|
|
18
|
+
let generation = 0;
|
|
19
|
+
const rememberOAuth = (credential: Credential) => Effect.sync(() => {
|
|
20
|
+
const key = sessionKey(credential);
|
|
21
|
+
oauthSessions.delete(key);
|
|
22
|
+
evictOldest(oauthSessions, 64);
|
|
23
|
+
oauthSessions.add(key);
|
|
24
|
+
return credential;
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
generation: Effect.sync(() => generation),
|
|
28
|
+
assertUsable: (credential) => Effect.suspend(() => invalid.has(credentialKey(credential))
|
|
29
|
+
? Effect.fail(RPCError.make("unauthenticated", "Authentication failed for this session. Sign in again with /login.")) : Effect.void),
|
|
30
|
+
// Kept as a plain error so it does not invalidate authentication or cancel a running request.
|
|
31
|
+
assertOAuthSession: (credential) => Effect.suspend(() => oauthSessions.has(sessionKey(credential)) ? Effect.void : failWith(OAUTH_ONLY_MESSAGE)),
|
|
32
|
+
rememberOAuth,
|
|
33
|
+
fromHost: (credential) => Effect.flatMap(credentialFromOAuth(credential), rememberOAuth),
|
|
34
|
+
markInvalid: (credential, error, expectedGeneration) => Effect.sync(() => {
|
|
35
|
+
if (expectedGeneration !== generation || !isAuthenticationError(error)) return false;
|
|
36
|
+
const key = credentialKey(credential);
|
|
37
|
+
if (invalid.has(key)) return false;
|
|
38
|
+
evictOldest(invalid, 64);
|
|
39
|
+
invalid.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
}),
|
|
42
|
+
acceptLogin: (credential) => Effect.gen(function* () {
|
|
43
|
+
invalid.delete(credentialKey(credential));
|
|
44
|
+
yield* rememberOAuth(credential);
|
|
45
|
+
return ++generation;
|
|
46
|
+
}),
|
|
47
|
+
} satisfies AuthStateShape;
|
|
48
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AuthInteraction, ModelAuth, OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { DevinError, DevinFailure, RPCError } from "../../../core/errors.ts";
|
|
4
|
+
import type { Credential } from "../../../core/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AuthStateShape {
|
|
7
|
+
/** Increases with every successful sign-in. Work captures it when it starts and acts on failures only within the same generation. */
|
|
8
|
+
readonly generation: Effect.Effect<number>;
|
|
9
|
+
/** Fails with an authentication error when this credential was invalidated. */
|
|
10
|
+
readonly assertUsable: (credential: Credential) => Effect.Effect<void, RPCError>;
|
|
11
|
+
/** Rejects sessions that did not come from an OAuth login or stored credential (API keys etc.) before any network request. */
|
|
12
|
+
readonly assertOAuthSession: (credential: Credential) => Effect.Effect<void, DevinError>;
|
|
13
|
+
/** Remembers the credential as this process's OAuth session. Pi resolves the session in use on every request, so it moves back to the front. */
|
|
14
|
+
readonly rememberOAuth: (credential: Credential) => Effect.Effect<Credential>;
|
|
15
|
+
/** Converts the OAuth credential stored by Pi into the shared credential and remembers it as an OAuth session. */
|
|
16
|
+
readonly fromHost: (credential: OAuthCredential) => Effect.Effect<Credential, DevinError>;
|
|
17
|
+
/** Marks a confirmed authentication failure of the given generation. True only when this call invalidated the credential. */
|
|
18
|
+
readonly markInvalid: (credential: Credential, error: unknown, generation: number) => Effect.Effect<boolean>;
|
|
19
|
+
/** A new sign-in: clears its invalid mark, remembers it and starts the next generation, which it returns. */
|
|
20
|
+
readonly acceptLogin: (credential: Credential) => Effect.Effect<number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CredentialGuardShape {
|
|
24
|
+
/**
|
|
25
|
+
* Acts on a failure seen while using the credential. Only a confirmed authentication failure of the current generation
|
|
26
|
+
* invalidates it; then its account record is forgotten and the running run stops with the same error. The credential file is never deleted.
|
|
27
|
+
*/
|
|
28
|
+
readonly reportFailure: (credential: Credential, error: unknown, generation: number) => Effect.Effect<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pi's OAuth flows. Each call stops with the provider lifetime current when it started and with the caller's signal. */
|
|
32
|
+
export interface AccountLoginShape {
|
|
33
|
+
readonly login: (interaction: AuthInteraction) => Effect.Effect<OAuthCredential, DevinFailure>;
|
|
34
|
+
readonly refresh: (current: OAuthCredential, signal: AbortSignal) => Effect.Effect<OAuthCredential, DevinFailure>;
|
|
35
|
+
readonly toAuth: (current: OAuthCredential) => Effect.Effect<ModelAuth, DevinError | RPCError>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Makes room in an insertion-ordered set or map by forgetting its oldest entry once it holds `limit` entries. */
|
|
2
|
+
export function evictOldest<T>(collection: Set<T> | Map<T, unknown>, limit: number): void {
|
|
3
|
+
if (collection.size >= limit) collection.delete(collection.keys().next().value!);
|
|
4
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DevinAPI } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const DEVIN_API: DevinAPI = "devin-connect";
|
|
4
|
+
export const CATALOG_VERSION = 1;
|
|
5
|
+
/** Default sidekick when nothing is saved or the saved one is gone. The default lead is the group this UID belongs to. */
|
|
6
|
+
export const FUSION_SIDEKICK_UID = "swe-2-medium";
|
|
7
|
+
/** The single Fusion entry shown in `/model`. Lead and sidekick follow the global `/devin-fusion` choice. */
|
|
8
|
+
export const FUSION_MODEL_ID = "fusion";
|
|
9
|
+
|
|
10
|
+
export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
|
|
@@ -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)));
|