@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.
Files changed (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -1,9 +1,15 @@
1
- import { parseModelCatalog, type ModelCatalog } from "./models/catalog.ts";
2
1
  import { createHash } from "node:crypto";
3
- import { ConnectClient, ASSIGN_MODEL_METHOD, endpoint, type ConnectClientOptions } from "./protocol/transport.ts";
4
- import { createFingerprintProvider, type FingerprintProvider } from "./protocol/fingerprint.ts";
2
+ import { Effect, Predicate, type Scope } from "effect";
3
+ import type { DevinError, DevinFailure } from "./errors.ts";
5
4
  import { wireMessages } from "./inference/messages.ts";
6
- import { object, requiredString, type ChatMessage, type Credential, type JsonObject, type ModelInfo } from "./types.ts";
5
+ import { parseModelCatalog, type ModelCatalog } from "./models/catalog.ts";
6
+ import { ASSIGN_MODEL_METHOD, makeConnectClient, type ConnectClient, type ConnectClientOptions } from "./protocol/connect-client.ts";
7
+ import { endpoint, isLoopbackHost } from "./protocol/endpoint.ts";
8
+ import { makeFingerprintProvider, type FingerprintProvider } from "./protocol/fingerprint.ts";
9
+ import { DeviceIdentitySource } from "./services/device-identity.ts";
10
+ import type { HttpTransport } from "./services/http-transport.ts";
11
+ import type { ChatMessage, Credential, JsonObject, ModelInfo } from "./types.ts";
12
+ import { failWith, object, requiredString } from "./validation.ts";
7
13
 
8
14
  export const CLIENT_VERSION = "3000.11.92";
9
15
  export type MetadataProfile = "agent" | "standalone" | "login";
@@ -11,143 +17,142 @@ const MODEL_DISPLAYS = [
11
17
  "DISPLAY_OPTION_MODEL_ROUTER", "DISPLAY_OPTION_QUICK_REVIEW", "DISPLAY_OPTION_HIDDEN_TOOL",
12
18
  "DISPLAY_OPTION_WIRE_7", "DISPLAY_OPTION_MODERN_AGENT_CHAT",
13
19
  ];
20
+ const STATUS_METHOD = "exa.seat_management_pb.SeatManagementService/GetUserStatus";
21
+ const MODELS_METHOD = "exa.api_server_pb.ApiServerService/GetCliModelConfigs";
14
22
 
15
23
  export interface DevinClientOptions extends Omit<ConnectClientOptions, "baseURL" | "token"> {
16
24
  profile?: MetadataProfile;
17
25
  restBaseURL?: string;
26
+ /** A provider shared with other clients of the same credential. Otherwise this client collects device values once for itself. */
18
27
  fingerprint?: FingerprintProvider;
19
28
  }
20
29
 
21
- export class DevinClient {
30
+ export interface StatusOptions {
31
+ /** Rejects a different signed-in user before anything else is sent. */
32
+ expectedUserId?: string;
33
+ profile?: MetadataProfile;
34
+ }
35
+
36
+ export interface DevinClient {
22
37
  readonly credential: Credential;
23
38
  readonly transport: ConnectClient;
24
39
  readonly profile: MetadataProfile;
25
40
  readonly fingerprint: FingerprintProvider;
26
- defaultModelUid?: string;
27
- accountIdentityHash?: string;
28
- private organizationId?: string;
29
- private restBaseURL: string;
30
- private readonly hasExplicitRestURL: boolean;
31
- constructor(credential: Credential, options: DevinClientOptions = {}) {
32
- this.credential = credential;
33
- const { profile, fingerprint, restBaseURL, ...transportOptions } = options;
34
- const rpcOrigin = endpoint(credential.apiServerURL);
35
- const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(rpcOrigin).hostname);
36
- this.hasExplicitRestURL = restBaseURL !== undefined;
37
- this.restBaseURL = endpoint(restBaseURL ?? (isLoopback ? rpcOrigin : "https://api.devin.ai"));
38
- this.profile = profile ?? "agent";
39
- this.fingerprint = fingerprint ?? createFingerprintProvider(credential.sessionToken);
40
- this.transport = new ConnectClient({ baseURL: credential.apiServerURL, token: credential.sessionToken, ...transportOptions });
41
- }
42
-
43
- metadata(options: { profile?: MetadataProfile; includeModelDisplays?: boolean } = {}): JsonObject {
44
- const profile = options.profile ?? this.profile;
45
- const version = profile === "standalone" ? "0.0.0-dev" : CLIENT_VERSION;
46
- return {
47
- apiKey: this.credential.sessionToken,
48
- ideName: profile === "agent" ? "devin-cli" : "chisel",
49
- ideVersion: version,
50
- extensionName: "chisel",
51
- extensionVersion: version,
52
- locale: "en",
53
- os: process.platform,
54
- ...(profile === "agent" ? { ideType: "chisel" } : {}),
55
- ...(options.includeModelDisplays ? { supportedModelDisplays: [...MODEL_DISPLAYS] } : {}),
56
- f: this.fingerprint.create(),
57
- };
58
- }
41
+ /** Default model of the last fetched catalog. */
42
+ readonly defaultModelUid: Effect.Effect<string | undefined>;
43
+ /** Hash of the endpoint and user ID from the last status check. */
44
+ readonly accountIdentityHash: Effect.Effect<string | undefined>;
45
+ readonly close: Effect.Effect<void>;
46
+ metadata(options?: { profile?: MetadataProfile; includeModelDisplays?: boolean }): Effect.Effect<JsonObject, DevinError>;
47
+ status(options?: StatusOptions): Effect.Effect<JsonObject, DevinFailure>;
48
+ readonly catalog: Effect.Effect<ModelCatalog, DevinFailure>;
49
+ readonly models: Effect.Effect<ModelInfo[], DevinFailure>;
50
+ readonly teamSettings: Effect.Effect<JsonObject, DevinFailure>;
51
+ readonly managedPlugins: Effect.Effect<JsonObject, DevinFailure>;
52
+ assignModel(routerUid: string, cascadeId: string, message: ChatMessage | undefined): Effect.Effect<{ modelUid: string; assignmentJwt: string }, DevinFailure>;
53
+ readonly self: Effect.Effect<JsonObject, DevinFailure>;
54
+ readonly integrations: Effect.Effect<JsonObject | undefined, DevinFailure>;
55
+ analytics(events: readonly JsonObject[]): Effect.Effect<void, DevinFailure>;
56
+ }
59
57
 
60
- async status(signal?: AbortSignal, expectedUserId?: string, profile = this.profile): Promise<JsonObject> {
61
- signal?.throwIfAborted();
62
- const response = await this.transport.unary(
63
- "exa.seat_management_pb.SeatManagementService/GetUserStatus", { metadata: this.metadata({ profile }) }, signal,
64
- );
65
- const user = object(response.userStatus, "userStatus");
66
- if (expectedUserId && user.userId !== expectedUserId) {
67
- throw new Error("The signed-in user ID differs from the requested account. Sign in again; no inference was run.");
68
- }
69
- const plan = object(user.planStatus ?? {}, "planStatus");
70
- const info = object(plan.planInfo ?? {}, "planInfo");
71
- const devin = object(info.devinInfo ?? {}, "devinInfo");
72
- const userId = requiredString(user.userId, "userStatus.userId");
73
- this.accountIdentityHash = createHash("sha256").update(`${this.transport.baseURL}\0${userId}`).digest("hex");
74
- this.organizationId = typeof devin.orgId === "string" ? devin.orgId : undefined;
75
- if (!this.hasExplicitRestURL && typeof devin.apiUrl === "string" && devin.apiUrl) {
76
- const candidate = endpoint(devin.apiUrl);
77
- if (candidate !== this.restBaseURL && candidate !== this.transport.baseURL) throw new Error("Unverified REST server. Specify it explicitly with --rest-server.");
78
- this.restBaseURL = candidate;
79
- }
80
- const hasQuota = info.billingStrategy === "BILLING_STRATEGY_QUOTA";
81
- // Name, email, organization ID and the raw response are not exposed.
82
- return {
83
- ...(expectedUserId ? { identityVerified: true } : {}),
84
- teamsTier: user.teamsTier,
85
- canUseCli: info.devinInfo ? devin.canUseCli === true : undefined,
86
- billingStrategy: info.billingStrategy,
87
- dailyQuotaRemainingPercent: plan.dailyQuotaRemainingPercent ?? (hasQuota ? 0 : undefined),
88
- weeklyQuotaRemainingPercent: plan.weeklyQuotaRemainingPercent ?? (hasQuota ? 0 : undefined),
89
- dailyQuotaResetAtUnix: plan.dailyQuotaResetAtUnix,
90
- weeklyQuotaResetAtUnix: plan.weeklyQuotaResetAtUnix,
91
- };
92
- }
58
+ /** A client for one credential. It closes with the scope, or earlier through `close`. */
59
+ export function makeDevinClient(credential: Credential, options: DevinClientOptions = {}): Effect.Effect<DevinClient, DevinError, HttpTransport | DeviceIdentitySource | Scope.Scope> {
60
+ return Effect.gen(function* () {
61
+ const { profile = "agent", fingerprint: providedFingerprint, restBaseURL: explicitRestURL, ...transportOptions } = options;
62
+ const rpcOrigin = yield* endpoint(credential.apiServerURL);
63
+ const hasExplicitRestURL = explicitRestURL !== undefined;
64
+ let restBaseURL = yield* endpoint(explicitRestURL ?? (isLoopbackHost(new URL(rpcOrigin).hostname) ? rpcOrigin : "https://api.devin.ai"));
65
+ const device = yield* DeviceIdentitySource;
66
+ const fingerprint = providedFingerprint ?? (yield* makeFingerprintProvider(credential.sessionToken, { collectIdentity: device.collect }));
67
+ const transport = yield* makeConnectClient({ baseURL: credential.apiServerURL, token: credential.sessionToken, ...transportOptions });
68
+ let defaultModelUid: string | undefined, accountIdentityHash: string | undefined, organizationId: string | undefined;
93
69
 
94
- async catalog(signal?: AbortSignal): Promise<ModelCatalog> {
95
- signal?.throwIfAborted();
96
- const response = await this.transport.unary(
97
- "exa.api_server_pb.ApiServerService/GetCliModelConfigs", { metadata: this.metadata({ includeModelDisplays: true }) }, signal,
98
- );
99
- const catalog = parseModelCatalog(response);
100
- this.defaultModelUid = catalog.defaultModelUid;
101
- return catalog;
102
- }
70
+ const metadata: DevinClient["metadata"] = (metadataOptions = {}) => Effect.map(fingerprint.create, (f) => {
71
+ const selected = metadataOptions.profile ?? profile;
72
+ const version = selected === "standalone" ? "0.0.0-dev" : CLIENT_VERSION;
73
+ return {
74
+ apiKey: credential.sessionToken,
75
+ ideName: selected === "agent" ? "devin-cli" : "chisel",
76
+ ideVersion: version,
77
+ extensionName: "chisel",
78
+ extensionVersion: version,
79
+ locale: "en",
80
+ os: process.platform,
81
+ ...(selected === "agent" ? { ideType: "chisel" } : {}),
82
+ ...(metadataOptions.includeModelDisplays ? { supportedModelDisplays: [...MODEL_DISPLAYS] } : {}),
83
+ f,
84
+ };
85
+ });
103
86
 
104
- async models(signal?: AbortSignal): Promise<ModelInfo[]> {
105
- return (await this.catalog(signal)).models;
106
- }
87
+ const status: DevinClient["status"] = (statusOptions = {}) => Effect.gen(function* () {
88
+ const response = yield* transport.unary(STATUS_METHOD, { metadata: yield* metadata({ profile: statusOptions.profile }) });
89
+ const user = yield* object(response.userStatus, "userStatus");
90
+ if (statusOptions.expectedUserId && user.userId !== statusOptions.expectedUserId) {
91
+ return yield* failWith("The signed-in user ID differs from the requested account. Sign in again; no inference was run.");
92
+ }
93
+ const plan = yield* object(user.planStatus ?? {}, "planStatus");
94
+ const info = yield* object(plan.planInfo ?? {}, "planInfo");
95
+ const devin = yield* object(info.devinInfo ?? {}, "devinInfo");
96
+ const userId = yield* requiredString(user.userId, "userStatus.userId");
97
+ accountIdentityHash = createHash("sha256").update(`${transport.baseURL}\0${userId}`).digest("hex");
98
+ organizationId = Predicate.isString(devin.orgId) ? devin.orgId : undefined;
99
+ if (!hasExplicitRestURL && Predicate.isString(devin.apiUrl) && devin.apiUrl) {
100
+ const candidate = yield* endpoint(devin.apiUrl);
101
+ if (candidate !== restBaseURL && candidate !== transport.baseURL) return yield* failWith("Unverified REST server. Specify it explicitly with --rest-server.");
102
+ restBaseURL = candidate;
103
+ }
104
+ const hasQuota = info.billingStrategy === "BILLING_STRATEGY_QUOTA";
105
+ // Name, email, organization ID and the raw response are not exposed.
106
+ return {
107
+ ...(statusOptions.expectedUserId ? { identityVerified: true } : {}),
108
+ teamsTier: user.teamsTier,
109
+ canUseCli: info.devinInfo ? devin.canUseCli === true : undefined,
110
+ billingStrategy: info.billingStrategy,
111
+ dailyQuotaRemainingPercent: plan.dailyQuotaRemainingPercent ?? (hasQuota ? 0 : undefined),
112
+ weeklyQuotaRemainingPercent: plan.weeklyQuotaRemainingPercent ?? (hasQuota ? 0 : undefined),
113
+ dailyQuotaResetAtUnix: plan.dailyQuotaResetAtUnix,
114
+ weeklyQuotaResetAtUnix: plan.weeklyQuotaResetAtUnix,
115
+ };
116
+ });
107
117
 
108
- async teamSettings(signal?: AbortSignal): Promise<JsonObject> {
109
- signal?.throwIfAborted();
110
- return this.transport.unary("exa.seat_management_pb.SeatManagementService/GetCliTeamSettings",
111
- { metadata: this.metadata({ includeModelDisplays: true }) }, signal);
112
- }
118
+ const catalog = Effect.gen(function* () {
119
+ const response = yield* transport.unary(MODELS_METHOD, { metadata: yield* metadata({ includeModelDisplays: true }) });
120
+ const parsed = yield* parseModelCatalog(response);
121
+ defaultModelUid = parsed.defaultModelUid;
122
+ return parsed;
123
+ });
113
124
 
114
- async managedPlugins(signal?: AbortSignal): Promise<JsonObject> {
115
- signal?.throwIfAborted();
116
- // Only fetches the manifest. This does not mean installing a remote plugin or running its commands is approved.
117
- return this.transport.unary("exa.api_server_pb.ApiServerService/GetAccountManagedPlugins", { metadata: this.metadata() }, signal);
118
- }
125
+ const assignModel: DevinClient["assignModel"] = (routerUid, cascadeId, message) => Effect.gen(function* () {
126
+ const prompt = message ? wireMessages([message])[0] : undefined;
127
+ if (prompt) delete prompt.promptCacheOptions;
128
+ const response = yield* transport.unary(ASSIGN_MODEL_METHOD, {
129
+ metadata: yield* metadata(), modelRouterUid: routerUid, cascadeId, ...(prompt ? { chatMessagePrompt: prompt } : {}),
130
+ });
131
+ const assignment = yield* object(response.assignment, "assignment");
132
+ return {
133
+ modelUid: yield* requiredString(assignment.modelUid, "assignment.modelUid"),
134
+ assignmentJwt: message ? yield* requiredString(assignment.assignmentJwt, "assignment.assignmentJwt") : String(assignment.assignmentJwt ?? ""),
135
+ };
136
+ });
119
137
 
120
- async assignModel(routerUid: string, cascadeId: string, message: ChatMessage | undefined, signal?: AbortSignal): Promise<{ modelUid: string; assignmentJwt: string }> {
121
- signal?.throwIfAborted();
122
- const prompt = message ? wireMessages([message])[0] : undefined;
123
- if (prompt) delete prompt.promptCacheOptions;
124
- const response = await this.transport.unary(ASSIGN_MODEL_METHOD, {
125
- metadata: this.metadata(), modelRouterUid: routerUid, cascadeId, ...(prompt ? { chatMessagePrompt: prompt } : {}),
126
- }, signal);
127
- const assignment = object(response.assignment, "assignment");
128
138
  return {
129
- modelUid: requiredString(assignment.modelUid, "assignment.modelUid"),
130
- assignmentJwt: message ? requiredString(assignment.assignmentJwt, "assignment.assignmentJwt") : String(assignment.assignmentJwt ?? ""),
139
+ credential, transport, profile, fingerprint, metadata, status, catalog, assignModel,
140
+ close: transport.close,
141
+ defaultModelUid: Effect.sync(() => defaultModelUid),
142
+ accountIdentityHash: Effect.sync(() => accountIdentityHash),
143
+ models: Effect.map(catalog, (parsed) => parsed.models),
144
+ teamSettings: Effect.flatMap(metadata({ includeModelDisplays: true }), (value) =>
145
+ transport.unary("exa.seat_management_pb.SeatManagementService/GetCliTeamSettings", { metadata: value })),
146
+ // Only fetches the manifest. This does not mean installing a remote plugin or running its commands is approved.
147
+ managedPlugins: Effect.flatMap(metadata(), (value) => transport.unary("exa.api_server_pb.ApiServerService/GetAccountManagedPlugins", { metadata: value })),
148
+ self: Effect.suspend(() => transport.getJSON(restBaseURL, "/v3/self")),
149
+ integrations: Effect.suspend(() => organizationId
150
+ ? transport.getJSON(restBaseURL, `/v3/organizations/${encodeURIComponent(organizationId)}/integrations`) : Effect.succeed(undefined)),
151
+ analytics: (events) => events.length === 0 ? Effect.void : Effect.asVoid(transport.unary("exa.product_analytics_pb.ProductAnalyticsService/BatchRecordAnalyticsEvents", {
152
+ events: [...events], apiKey: credential.sessionToken,
153
+ })),
131
154
  };
132
- }
133
-
134
- async self(signal?: AbortSignal): Promise<JsonObject> {
135
- return this.transport.getJSON(this.restBaseURL, "/v3/self", signal);
136
- }
137
-
138
- async integrations(signal?: AbortSignal): Promise<JsonObject | undefined> {
139
- if (!this.organizationId) return undefined;
140
- return this.transport.getJSON(this.restBaseURL, `/v3/organizations/${encodeURIComponent(this.organizationId)}/integrations`, signal);
141
- }
142
-
143
- async analytics(events: JsonObject[], signal?: AbortSignal): Promise<void> {
144
- signal?.throwIfAborted();
145
- if (events.length === 0) return;
146
- await this.transport.unary("exa.product_analytics_pb.ProductAnalyticsService/BatchRecordAnalyticsEvents", {
147
- events, apiKey: this.credential.sessionToken,
148
- }, signal);
149
- }
155
+ });
150
156
  }
151
157
 
152
-
153
158
  export { getModelByUID, selectModel } from "./models/select.ts";
@@ -0,0 +1,115 @@
1
+ import { Data, Predicate } from "effect";
2
+
3
+ /** A failure whose message is safe to show. `code` keeps a Node or library error code, such as `ECONNRESET`, for classification. */
4
+ export class DevinError extends Data.TaggedError("DevinError")<{
5
+ readonly message: string;
6
+ readonly code?: string | undefined;
7
+ }> {}
8
+
9
+ /** Cancellation by the caller, the lifecycle or a closed client. Keeps the `AbortError` name hosts recognize. */
10
+ export class Cancelled extends Data.TaggedError("Cancelled")<{ readonly message: string }> {
11
+ override get name(): string { return "AbortError"; }
12
+ }
13
+
14
+ /** A local deadline or a host timeout. Keeps the `TimeoutError` name hosts recognize. */
15
+ export class RequestTimeout extends Data.TaggedError("RequestTimeout")<{ readonly message: string }> {
16
+ override get name(): string { return "TimeoutError"; }
17
+ }
18
+
19
+ export type RPCFailureKind = "authentication" | "permission" | "rate_limit" | "timeout" | "rpc";
20
+
21
+ function rpcFailureKind(code: string, httpStatus: number | undefined): RPCFailureKind {
22
+ // An error body that contradicts the HTTP status does not invalidate authentication.
23
+ if (httpStatus !== undefined && httpStatus >= 400) {
24
+ if (httpStatus === 401) return "authentication";
25
+ if (httpStatus === 403) return "permission";
26
+ if (httpStatus === 429) return "rate_limit";
27
+ if (httpStatus === 408 || httpStatus === 504) return "timeout";
28
+ return "rpc";
29
+ }
30
+ if (code === "unauthenticated") return "authentication";
31
+ if (code === "permission_denied") return "permission";
32
+ if (code === "resource_exhausted") return "rate_limit";
33
+ if (code === "deadline_exceeded") return "timeout";
34
+ return "rpc";
35
+ }
36
+
37
+ const AUTHENTICATION_NOTICE = " The authentication credential is not valid. It may have expired or been revoked; sign in again with login.";
38
+
39
+ /** An error the server reported over HTTP or Connect. `message` is already `code: detail`. */
40
+ export class RPCError extends Data.TaggedError("RPCError")<{
41
+ readonly code: string;
42
+ readonly message: string;
43
+ readonly httpStatus?: number | undefined;
44
+ readonly retryAfterMs?: number | undefined;
45
+ }> {
46
+ static make(code: string, detail: string, httpStatus?: number, retryAfterMs?: number): RPCError {
47
+ const message = `${code}: ${detail}`;
48
+ return new RPCError({
49
+ code, httpStatus, retryAfterMs,
50
+ message: rpcFailureKind(code, httpStatus) === "authentication" ? message.slice(0, 600) + AUTHENTICATION_NOTICE : message,
51
+ });
52
+ }
53
+
54
+ get kind(): RPCFailureKind { return rpcFailureKind(this.code, this.httpStatus); }
55
+ }
56
+
57
+ export type DevinFailure = DevinError | Cancelled | RequestTimeout | RPCError;
58
+
59
+ const FAILURE_TAGS = new Set(["DevinError", "Cancelled", "RequestTimeout", "RPCError"]);
60
+
61
+ export function isDevinFailure(value: unknown): value is DevinFailure {
62
+ return value instanceof Error && Predicate.hasProperty(value, "_tag") && FAILURE_TAGS.has(value._tag as string);
63
+ }
64
+
65
+ /** The Node error code of a failure or of its cause, used to tell network failures apart. */
66
+ function errorCode(value: unknown): string | undefined {
67
+ if (Predicate.hasProperty(value, "code") && Predicate.isString(value.code)) return value.code;
68
+ const cause = value instanceof Error ? value.cause : undefined;
69
+ return Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code) ? cause.code : undefined;
70
+ }
71
+
72
+ /** Turns anything thrown or rejected by foreign code, including abort reasons, into a typed failure. */
73
+ export function toDevinFailure(cause: unknown): DevinFailure {
74
+ if (isDevinFailure(cause)) return cause;
75
+ const message = cause instanceof Error ? cause.message : String(cause);
76
+ const name = cause instanceof Error ? cause.name : undefined;
77
+ if (name === "AbortError") return new Cancelled({ message });
78
+ if (name === "TimeoutError") return new RequestTimeout({ message });
79
+ return new DevinError({ message, code: errorCode(cause) });
80
+ }
81
+
82
+ /** Like `toDevinFailure` for synchronous library calls that cannot be cancelled: always a `DevinError`. */
83
+ export function toDevinError(cause: unknown): DevinError {
84
+ return cause instanceof DevinError ? cause : new DevinError({ message: cause instanceof Error ? cause.message : String(cause), code: errorCode(cause) });
85
+ }
86
+
87
+ export function isAuthenticationError(error: unknown): error is RPCError {
88
+ return error instanceof RPCError && error.kind === "authentication";
89
+ }
90
+
91
+ const NETWORK_CODES = /^(ECONN|ENET|EHOST|EPIPE$|ENOTFOUND$|EAI_|ETIMEDOUT$|ERR_TLS_|CERT_|DEPTH_ZERO_SELF_SIGNED_CERT$|SELF_SIGNED_CERT_IN_CHAIN$|UNABLE_TO_)/;
92
+ const TRANSIENT_CONNECTION_CODES = /^(ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENETDOWN|ENOTFOUND|EAI_AGAIN|ERR_STREAM_PREMATURE_CLOSE)$/;
93
+
94
+ /** Failure classification for reports. Never includes a message, token or identifier. */
95
+ export function failureDetails(error: unknown): Record<string, unknown> {
96
+ const failure = toDevinFailure(error);
97
+ switch (failure._tag) {
98
+ case "RPCError": return {
99
+ category: failure.kind, code: failure.code,
100
+ ...(failure.httpStatus === undefined ? {} : { httpStatus: failure.httpStatus }),
101
+ ...(failure.retryAfterMs === undefined ? {} : { retryAfterMs: failure.retryAfterMs }),
102
+ ...(failure.kind === "authentication" ? { requiresLogin: true } : {}),
103
+ };
104
+ case "RequestTimeout": return { category: "timeout", code: "deadline_exceeded" };
105
+ case "Cancelled": return { category: "cancelled" };
106
+ case "DevinError": return failure.code !== undefined && NETWORK_CODES.test(failure.code) ? { category: "network", code: failure.code } : { category: "error" };
107
+ }
108
+ }
109
+
110
+ /** The request got no usable answer from a working server, so sending it again may succeed. TLS and certificate failures are not included. */
111
+ export function isTransientConnectionFailure(error: unknown): boolean {
112
+ const failure = toDevinFailure(error);
113
+ if (failure instanceof RPCError) return failure.code === "unavailable" || [502, 503, 504].includes(failure.httpStatus ?? 0);
114
+ return failure instanceof DevinError && failure.code !== undefined && TRANSIENT_CONNECTION_CODES.test(failure.code);
115
+ }
package/src/core/index.ts CHANGED
@@ -1,12 +1,26 @@
1
- /** Host-independent API shared by the CLI and Pi. */
2
- export const CORE_CONTRACT_VERSION = 1;
1
+ /**
2
+ * Host-independent API shared by the CLI and Pi. Version 2 returns Effect values instead of promises;
3
+ * version 3 takes the HTTP transport and device identity collection from services instead of options.
4
+ */
5
+ export const CORE_CONTRACT_VERSION = 3;
3
6
  export * from "./types.ts";
7
+ export * from "./errors.ts";
8
+ export * from "./cancellation.ts";
9
+ export * from "./validation.ts";
10
+ export * from "./redaction.ts";
4
11
  export * from "./client.ts";
5
12
  export * from "./auth/index.ts";
6
13
  export * from "./models/catalog.ts";
7
- export * from "./inference/chat.ts";
14
+ export * from "./inference/request.ts";
15
+ export * from "./inference/accumulator.ts";
16
+ export * from "./inference/stream-chat.ts";
8
17
  export * from "./inference/messages.ts";
9
18
  export * from "./inference/retry.ts";
10
19
  export * from "./inference/state.ts";
11
- export * from "./protocol/transport.ts";
20
+ export * from "./protocol/connect-client.ts";
21
+ export * from "./protocol/frames.ts";
22
+ export * from "./protocol/endpoint.ts";
12
23
  export * from "./protocol/fingerprint.ts";
24
+ export * from "./services/http-transport.ts";
25
+ export * from "./services/device-identity.ts";
26
+ export * from "./services/platform.ts";
@@ -0,0 +1,174 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect, Predicate } from "effect";
3
+ import { DevinError } from "../errors.ts";
4
+ import type { ChatMessage, ChatResult, JsonObject, ToolCall, Usage } from "../types.ts";
5
+ import { failWith, object, requiredString, tokenCount } from "../validation.ts";
6
+
7
+ // Backends mark a finished turn differently: Anthropic text ends with MIN_LOG_PROB, Gemini tool calls with STOP_PATTERN,
8
+ // and OpenAI sends no stop reason (UNSPECIFIED is the proto3 default, so it is not on the wire).
9
+ const COMPLETE_STOP_REASONS = new Set(["STOP_REASON_STOP_PATTERN", "STOP_REASON_FUNCTION_CALL", "STOP_REASON_MIN_LOG_PROB", "STOP_REASON_UNSPECIFIED", ""]);
10
+ const COPIED_STRINGS = [
11
+ ["deltaSignatureType", "signatureType"], ["geminiThoughtSignature", "geminiThoughtSignature"],
12
+ ["outputId", "outputId"], ["thinkingId", "thinkingId"], ["phase", "phase"],
13
+ ] as const;
14
+ const USAGE_FIELDS = [["inputTokens", "input"], ["outputTokens", "output"], ["cacheReadTokens", "cacheRead"], ["cacheWriteTokens", "cacheWrite"]] as const;
15
+
16
+ const SERVER_JSON_ERROR = "The server reported a tool argument JSON error.";
17
+
18
+ /**
19
+ * Fails when a finished result has a call that must not run: arguments that are not a JSON object, or calls cut off at the output limit.
20
+ * The CLI runs tools itself and uses this; Pi reports such calls back to the model instead.
21
+ */
22
+ export function assertRunnableToolCalls(result: ChatResult): Effect.Effect<void, DevinError> {
23
+ return Effect.gen(function* () {
24
+ for (const call of result.message.toolCalls ?? []) {
25
+ const error = result.toolArgumentErrors[call.id];
26
+ if (error) return yield* failWith(error.startsWith(SERVER_JSON_ERROR) ? SERVER_JSON_ERROR : `Arguments of tool ${call.name} are not valid JSON.`);
27
+ }
28
+ if (result.message.toolCalls?.length && !COMPLETE_STOP_REASONS.has(result.stopReason)) return yield* failWith("Tool calls and the stop reason do not match.");
29
+ });
30
+ }
31
+
32
+ /** Pi's wording for arguments that are not a JSON object: the problem, then the text the model sent. */
33
+ function argumentsError(call: ToolCall): string | undefined {
34
+ try {
35
+ const value = JSON.parse(call.argumentsJson) as unknown;
36
+ if (Predicate.isObject(value) && !Array.isArray(value)) return undefined;
37
+ return `Tool arguments must be a JSON object\n\nReceived arguments:\n${call.argumentsJson}`;
38
+ } catch (error) {
39
+ return `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${call.argumentsJson}`;
40
+ }
41
+ }
42
+
43
+ /** Attaches argument deltas without an ID to the call that started with the first ID. Ambiguous parallel deltas are rejected. */
44
+ export class ChatAccumulator {
45
+ readonly message: ChatMessage = { messageId: "", source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
46
+ readonly usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
47
+ readonly calls = new Map<string, ToolCall>();
48
+ /** Calls the server marked as invalid JSON. Their arguments are reported, never parsed into a runnable call. */
49
+ private readonly serverArgumentErrors = new Set<string>();
50
+ private currentCall?: ToolCall;
51
+ private hasEnded = false;
52
+ private readonly startedAt = performance.now();
53
+ private firstDeltaMs?: number;
54
+ private stopReason = "";
55
+ private usageReported = false;
56
+ private frameCount = 0;
57
+ private stopFrameIndex?: number;
58
+ private lastUsageFrameIndex?: number;
59
+ private idlessToolDeltaCount = 0;
60
+ private actualModelUid?: string;
61
+ private usageModelUid?: string;
62
+
63
+ accept(data: JsonObject, isEnd = false): Effect.Effect<void, DevinError> {
64
+ const self = this;
65
+ return Effect.gen(function* () {
66
+ if (self.hasEnded) return yield* failWith("Cannot accumulate a response after EndStream.");
67
+ self.frameCount++;
68
+ if (isEnd) {
69
+ if (data.error) return yield* failWith("An error EndStream cannot be treated as success.");
70
+ self.hasEnded = true;
71
+ return;
72
+ }
73
+ if (data.redact === true) return yield* failWith("The server redacted the response.");
74
+ const message = self.message;
75
+ if (Predicate.isString(data.messageId) && data.messageId) message.messageId = data.messageId;
76
+ if (data.thinkingRedacted === true) {
77
+ message.thinkingRedacted = true;
78
+ message.thinking = "";
79
+ }
80
+ if (Predicate.isString(data.deltaText)) message.prompt += data.deltaText;
81
+ if (Predicate.isString(data.deltaThinking) && !message.thinkingRedacted) message.thinking = (message.thinking ?? "") + data.deltaThinking;
82
+ if (Predicate.isString(data.deltaSignature)) message.signature = (message.signature ?? "") + data.deltaSignature;
83
+ for (const [source, target] of COPIED_STRINGS) {
84
+ const value = data[source];
85
+ if (Predicate.isString(value)) message[target] = value;
86
+ }
87
+ if (Predicate.isString(data.actualModelUid)) self.actualModelUid = data.actualModelUid;
88
+ if (data.deltaToolCalls !== undefined) yield* self.acceptToolDeltas(data.deltaToolCalls);
89
+ if (data.deltaText || data.deltaThinking || data.deltaToolCalls) self.firstDeltaMs ??= performance.now() - self.startedAt;
90
+ if (Predicate.isString(data.stopReason) && data.stopReason) {
91
+ self.stopReason = data.stopReason;
92
+ self.stopFrameIndex = self.frameCount;
93
+ }
94
+ if (data.usage !== undefined) {
95
+ const usage = yield* object(data.usage, "usage");
96
+ if (Predicate.isString(usage.modelUid)) self.usageModelUid = usage.modelUid;
97
+ for (const [source, target] of USAGE_FIELDS) {
98
+ if (usage[source] === undefined) continue;
99
+ self.usage[target] = yield* tokenCount(usage[source], source);
100
+ self.usageReported = true;
101
+ self.lastUsageFrameIndex = self.frameCount;
102
+ }
103
+ self.usage.totalTokens = self.usage.input + self.usage.output + self.usage.cacheRead + self.usage.cacheWrite;
104
+ }
105
+ });
106
+ }
107
+
108
+ private acceptToolDeltas(deltas: unknown): Effect.Effect<void, DevinError> {
109
+ const self = this;
110
+ return Effect.gen(function* () {
111
+ if (!Array.isArray(deltas)) return yield* failWith("deltaToolCalls must be an array.");
112
+ for (const value of deltas) {
113
+ const delta = yield* object(value, "tool delta");
114
+ const id = Predicate.isString(delta.id) && delta.id ? delta.id : undefined;
115
+ if (id) {
116
+ let call = self.calls.get(id);
117
+ if (!call) {
118
+ call = { id, name: "", argumentsJson: "" };
119
+ self.calls.set(id, call);
120
+ }
121
+ self.currentCall = call;
122
+ } else {
123
+ self.idlessToolDeltaCount++;
124
+ if (deltas.length !== 1 || !self.currentCall || self.calls.size > 1) {
125
+ return yield* failWith("Cannot attach a delta without a tool ID to a specific call.");
126
+ }
127
+ }
128
+ const call = self.currentCall!;
129
+ if (delta.isCustomToolCall) return yield* failWith("Grammar-based custom tools are not supported.");
130
+ if (delta.invalidJsonErr || delta.invalidJsonStr) self.serverArgumentErrors.add(call.id);
131
+ if (Predicate.isString(delta.name)) call.name += delta.name;
132
+ if (Predicate.isString(delta.argumentsJson)) call.argumentsJson += delta.argumentsJson;
133
+ }
134
+ });
135
+ }
136
+
137
+ get finish(): Effect.Effect<ChatResult, DevinError> {
138
+ const self = this;
139
+ return Effect.gen(function* () {
140
+ if (!self.hasEnded) return yield* failWith("No successful EndStream.");
141
+ if (!COMPLETE_STOP_REASONS.has(self.stopReason) && self.stopReason !== "STOP_REASON_MAX_TOKENS") {
142
+ return yield* failWith(`stopReason cannot be treated as success: ${self.stopReason || "(none)"}`);
143
+ }
144
+ const toolArgumentErrors: Record<string, string> = {};
145
+ if (self.calls.size > 0) {
146
+ self.message.toolCalls = [...self.calls.values()];
147
+ for (const call of self.message.toolCalls) {
148
+ yield* requiredString(call.name, "tool name");
149
+ const error = self.serverArgumentErrors.has(call.id) ? `${SERVER_JSON_ERROR}\n\nReceived arguments:\n${call.argumentsJson}` : argumentsError(call);
150
+ if (error) toolArgumentErrors[call.id] = error;
151
+ }
152
+ }
153
+ if (self.stopReason === "STOP_REASON_FUNCTION_CALL" && !self.calls.size) {
154
+ return yield* failWith("The response ended with FUNCTION_CALL but has no tool call.");
155
+ }
156
+ self.message.messageId ||= randomUUID();
157
+ return {
158
+ message: self.message,
159
+ stopReason: self.stopReason,
160
+ usage: self.usage,
161
+ usageReported: self.usageReported,
162
+ frameCount: self.frameCount,
163
+ stopFrameIndex: self.stopFrameIndex,
164
+ lastUsageFrameIndex: self.lastUsageFrameIndex,
165
+ idlessToolDeltaCount: self.idlessToolDeltaCount,
166
+ toolArgumentErrors,
167
+ durationMs: Math.round(performance.now() - self.startedAt),
168
+ firstDeltaMs: self.firstDeltaMs === undefined ? undefined : Math.round(self.firstDeltaMs),
169
+ actualModelUid: self.actualModelUid,
170
+ ...(self.usageModelUid ? { usageModelUid: self.usageModelUid } : {}),
171
+ };
172
+ });
173
+ }
174
+ }