@buyong/pi-devin 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { spawn } from "node:child_process";
|
|
2
|
+
import type { Deferred, Effect } from "effect";
|
|
3
|
+
import type { DevinError, DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface SidekickCommand { command: string; args: string[] }
|
|
6
|
+
|
|
7
|
+
/** Launch settings of the sidekick child Pi. When they change, the process restarts before the next handoff. */
|
|
8
|
+
export interface SidekickLaunch {
|
|
9
|
+
cwd: string;
|
|
10
|
+
/** Devin model ID of the exact variant (its catalog UID). */
|
|
11
|
+
modelID: string;
|
|
12
|
+
thinking?: string;
|
|
13
|
+
/** Without it, the child starts with `--no-session`. */
|
|
14
|
+
sessionFile?: string;
|
|
15
|
+
tools: readonly string[];
|
|
16
|
+
systemPrompt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HandoffProgress { toolCalls: number; recentTools: string[]; elapsedMs: number }
|
|
20
|
+
export interface HandoffReport {
|
|
21
|
+
id: string;
|
|
22
|
+
status: "completed" | "aborted" | "error";
|
|
23
|
+
text: string;
|
|
24
|
+
toolCalls: number;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A handoff and its single report. A steer shares the running handoff's report. */
|
|
29
|
+
export interface Handoff { id: string; done: Deferred.Deferred<HandoffReport>; isSteer: boolean }
|
|
30
|
+
|
|
31
|
+
export interface BoundedReport {
|
|
32
|
+
text: string;
|
|
33
|
+
/** Private temporary file with the complete report, set only when the report was cut and the file was written. */
|
|
34
|
+
fullReportPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SidekickDetails {
|
|
38
|
+
sidekickModel: string;
|
|
39
|
+
handoffId: string;
|
|
40
|
+
status: HandoffReport["status"] | "running" | "background";
|
|
41
|
+
toolCalls: number;
|
|
42
|
+
durationMs?: number;
|
|
43
|
+
isSteer: boolean;
|
|
44
|
+
/** Complete report saved when the text the lead received was cut. */
|
|
45
|
+
fullReportPath?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a sidekick child Pi is started. */
|
|
49
|
+
export interface SidekickLauncherShape {
|
|
50
|
+
/** Command that runs Pi. The default is the same Node and Pi CLI as the lead. */
|
|
51
|
+
readonly resolveCommand: Effect.Effect<SidekickCommand, DevinError>;
|
|
52
|
+
/**
|
|
53
|
+
* Starts the child. Node's spawner is used directly rather than Effect's: the child stays in the lead's process group,
|
|
54
|
+
* so terminal signals reach it, and a broken stdin is noticed while writing.
|
|
55
|
+
*/
|
|
56
|
+
readonly spawn: typeof spawn;
|
|
57
|
+
readonly env: NodeJS.ProcessEnv;
|
|
58
|
+
/** Passed to the child with `-e`, so it loads the same extension copy as the lead. */
|
|
59
|
+
readonly packageRoot: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the sidekick needs from the lead Pi. */
|
|
63
|
+
export interface SidekickHostShape {
|
|
64
|
+
/** The lead's active tools. The child gets them without the sidekick tool itself. */
|
|
65
|
+
readonly activeTools: Effect.Effect<readonly string[]>;
|
|
66
|
+
/** Delivers a background report to the lead as a follow-up message that starts a turn. */
|
|
67
|
+
readonly deliverReport: (report: { content: string; details: SidekickDetails }) => Effect.Effect<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One `devin_sidekick` call. */
|
|
71
|
+
export interface HandoffRequest {
|
|
72
|
+
/** Tool parameters as Pi passed them. */
|
|
73
|
+
params: unknown;
|
|
74
|
+
/** The lead's model. Only the Devin Fusion model may hand off. */
|
|
75
|
+
model: { provider?: string; api?: string; id: string } | undefined;
|
|
76
|
+
cwd: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
/** The lead session is saved to a file; the child then keeps its own session file. */
|
|
79
|
+
isSessionSaved: boolean;
|
|
80
|
+
signal: AbortSignal | undefined;
|
|
81
|
+
/** Progress while a blocking call waits for the report. */
|
|
82
|
+
onProgress?: (text: string, details: SidekickDetails) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HandoffResult { content: { type: "text"; text: string }[]; details: SidekickDetails }
|
|
86
|
+
|
|
87
|
+
export interface SidekicksShape {
|
|
88
|
+
/**
|
|
89
|
+
* Starts or steers the session's handoff. By default waits for the report; with `block: false`, or when user input arrives
|
|
90
|
+
* while waiting, the report moves to the background and is delivered once.
|
|
91
|
+
*/
|
|
92
|
+
readonly handoff: (request: HandoffRequest) => Effect.Effect<HandoffResult, DevinFailure>;
|
|
93
|
+
/** User input moves a waiting handoff to the background. Input sent by extensions does not count. */
|
|
94
|
+
readonly userInput: (source: string) => Effect.Effect<void>;
|
|
95
|
+
/** Stops the sidekicks of other lead sessions. */
|
|
96
|
+
readonly sessionStarted: (sessionId: string) => Effect.Effect<void>;
|
|
97
|
+
/** Stops every sidekick. */
|
|
98
|
+
readonly reset: Effect.Effect<void>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { UsageChangesShape } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Change notices for displays such as the status line. Owners of account records and the current credential publish;
|
|
6
|
+
* listeners run synchronously inside the publishing effect, so a redraw sees the state that caused it.
|
|
7
|
+
*/
|
|
8
|
+
export class UsageChanges extends Context.Service<UsageChanges, UsageChangesShape>()("pi-devin/UsageChanges") {}
|
|
9
|
+
|
|
10
|
+
export const UsageChangesLive = Layer.sync(UsageChanges, () => {
|
|
11
|
+
const listeners = new Set<{ readonly listener: Effect.Effect<void> }>();
|
|
12
|
+
return {
|
|
13
|
+
// A display failure must not affect requests or refreshes.
|
|
14
|
+
publish: Effect.suspend(() => Effect.forEach([...listeners], (entry) => Effect.catchCause(entry.listener, () => Effect.void), { discard: true })),
|
|
15
|
+
subscribe: (listener) => Effect.asVoid(Effect.acquireRelease(
|
|
16
|
+
Effect.sync(() => { const entry = { listener }; listeners.add(entry); return entry; }),
|
|
17
|
+
(entry) => Effect.sync(() => { listeners.delete(entry); }),
|
|
18
|
+
)),
|
|
19
|
+
} satisfies UsageChangesShape;
|
|
20
|
+
});
|
|
@@ -1,31 +1,6 @@
|
|
|
1
|
-
import type { JsonObject } from "
|
|
1
|
+
import type { JsonObject } from "../../../core/types.ts";
|
|
2
|
+
import type { AccountUsage, SessionUsage, UsageLine, UsagePainter, UsageTone } from "./types.ts";
|
|
2
3
|
|
|
3
|
-
/** Quota from the account status (`GetUserStatus`). Values may have changed since it was fetched. */
|
|
4
|
-
export interface AccountUsage {
|
|
5
|
-
billingStrategy?: string;
|
|
6
|
-
dailyRemainingPercent?: number;
|
|
7
|
-
weeklyRemainingPercent?: number;
|
|
8
|
-
dailyResetAtMs?: number;
|
|
9
|
-
weeklyResetAtMs?: number;
|
|
10
|
-
fetchedAtMs: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Totals of Devin assistant responses recorded in the current session branch. Sidekick child conversations are separate sessions and are not included. */
|
|
14
|
-
export interface SessionUsage {
|
|
15
|
-
requests: number;
|
|
16
|
-
input: number;
|
|
17
|
-
output: number;
|
|
18
|
-
cacheRead: number;
|
|
19
|
-
cacheWrite: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface BudgetUsage { used: number; limit: number; isActive: boolean }
|
|
23
|
-
|
|
24
|
-
/** Color level by remaining amount. Matches the Pi theme color names `success`, `warning` and `error`. */
|
|
25
|
-
export type UsageTone = "success" | "warning" | "error";
|
|
26
|
-
export interface UsageLine { text: string; isHeading?: boolean; tone?: UsageTone }
|
|
27
|
-
/** Colors a segment. The default returns it uncolored. */
|
|
28
|
-
export type UsagePainter = (tone: UsageTone, text: string) => string;
|
|
29
4
|
const plain: UsagePainter = (_tone, text) => text;
|
|
30
5
|
|
|
31
6
|
const QUOTA_STRATEGY = "BILLING_STRATEGY_QUOTA";
|
|
@@ -87,10 +62,6 @@ export function remainingTone(percent: number): UsageTone {
|
|
|
87
62
|
return percent <= 20 ? "error" : percent <= 40 ? "warning" : "success";
|
|
88
63
|
}
|
|
89
64
|
|
|
90
|
-
function budgetTone(used: number, limit: number): UsageTone {
|
|
91
|
-
return used >= limit ? "error" : used / limit >= 0.75 ? "warning" : "success";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
65
|
function hasQuota(account: AccountUsage | undefined): account is AccountUsage & { dailyRemainingPercent: number; weeklyRemainingPercent: number } {
|
|
95
66
|
return account?.billingStrategy === QUOTA_STRATEGY && account.dailyRemainingPercent !== undefined && account.weeklyRemainingPercent !== undefined;
|
|
96
67
|
}
|
|
@@ -127,7 +98,7 @@ export function relativeTime(targetMs: number, nowMs: number): string {
|
|
|
127
98
|
}
|
|
128
99
|
|
|
129
100
|
/** Details shown by `/devin`. Built only from the last account status and session records; sends no requests. */
|
|
130
|
-
export function usageReport(account: AccountUsage | undefined, session: SessionUsage,
|
|
101
|
+
export function usageReport(account: AccountUsage | undefined, session: SessionUsage, nowMs: number): UsageLine[] {
|
|
131
102
|
const lines: UsageLine[] = [];
|
|
132
103
|
if (!account) {
|
|
133
104
|
lines.push({ text: "Account quota · not received yet", isHeading: true });
|
|
@@ -147,7 +118,5 @@ export function usageReport(account: AccountUsage | undefined, session: SessionU
|
|
|
147
118
|
lines.push({ text: "This session · lead conversation (sidekick excluded)", isHeading: true });
|
|
148
119
|
lines.push({ text: ` ${session.requests} ${session.requests === 1 ? "request" : "requests"} · input ${formatTokens(session.input)} · output ${formatTokens(session.output)}`
|
|
149
120
|
+ ` · cache read ${formatTokens(session.cacheRead)} · cache write ${formatTokens(session.cacheWrite)}` });
|
|
150
|
-
lines.push({ text: `Run budget · ${budget.isActive ? "in progress" : "last run"}`, isHeading: true });
|
|
151
|
-
lines.push({ text: ` ${progressBar((budget.used / budget.limit) * 100)} ${budget.used}/${budget.limit} model calls used`, tone: budgetTone(budget.used, budget.limit) });
|
|
152
121
|
return lines;
|
|
153
122
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Effect, Scope } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Quota from the account status (`GetUserStatus`). Values may have changed since it was fetched. */
|
|
4
|
+
export interface AccountUsage {
|
|
5
|
+
billingStrategy?: string;
|
|
6
|
+
dailyRemainingPercent?: number;
|
|
7
|
+
weeklyRemainingPercent?: number;
|
|
8
|
+
dailyResetAtMs?: number;
|
|
9
|
+
weeklyResetAtMs?: number;
|
|
10
|
+
fetchedAtMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Totals of Devin assistant responses recorded in the current session branch. Sidekick child conversations are separate sessions and are not included. */
|
|
14
|
+
export interface SessionUsage {
|
|
15
|
+
requests: number;
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Color level by remaining amount. Matches the Pi theme color names `success`, `warning` and `error`. */
|
|
23
|
+
export type UsageTone = "success" | "warning" | "error";
|
|
24
|
+
export interface UsageLine { text: string; isHeading?: boolean; tone?: UsageTone }
|
|
25
|
+
/** Colors a segment. The default returns it uncolored. */
|
|
26
|
+
export type UsagePainter = (tone: UsageTone, text: string) => string;
|
|
27
|
+
|
|
28
|
+
export interface UsageChangesShape {
|
|
29
|
+
/** Tells every listener, in order, that the quota or the current credential may have changed. Listener failures never reach the caller. */
|
|
30
|
+
readonly publish: Effect.Effect<void>;
|
|
31
|
+
/** Listens until the scope closes. */
|
|
32
|
+
readonly subscribe: (listener: Effect.Effect<void>) => Effect.Effect<void, never, Scope.Scope>;
|
|
33
|
+
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { object, requiredString, tokenCount, type ChatMessage, type ChatResult, type ChatTool, type ImageData, type JsonObject, type ToolCall, type Usage } from "../types.ts";
|
|
3
|
-
import type { ConnectClient } from "../protocol/transport.ts";
|
|
4
|
-
import { CHAT_METHOD } from "../protocol/protobuf.ts";
|
|
5
|
-
import { wireMessages } from "./messages.ts";
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Follow the user\'s request concisely.";
|
|
8
|
-
|
|
9
|
-
export function userMessage(prompt: string, images: readonly ImageData[] = []): ChatMessage {
|
|
10
|
-
return {
|
|
11
|
-
messageId: randomUUID(), source: "CHAT_MESSAGE_SOURCE_USER", prompt,
|
|
12
|
-
...(images.length ? { images: images.map((image) => ({ ...image })) } : {}),
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function chatRequest(options: {
|
|
17
|
-
metadata: JsonObject;
|
|
18
|
-
model: string;
|
|
19
|
-
messages: ChatMessage[];
|
|
20
|
-
systemPrompt: string;
|
|
21
|
-
maxTokens: number;
|
|
22
|
-
cascadeId: string;
|
|
23
|
-
trajectoryId: string;
|
|
24
|
-
executionId?: string;
|
|
25
|
-
clientSessionId?: string;
|
|
26
|
-
modelAssignmentJwt?: string;
|
|
27
|
-
stepIndex?: number;
|
|
28
|
-
isAuxiliary?: boolean;
|
|
29
|
-
isRecovery?: boolean;
|
|
30
|
-
includeContext?: boolean;
|
|
31
|
-
cwd?: string;
|
|
32
|
-
tools?: ChatTool[];
|
|
33
|
-
}): JsonObject {
|
|
34
|
-
return {
|
|
35
|
-
metadata: options.metadata,
|
|
36
|
-
prompt: options.systemPrompt,
|
|
37
|
-
chatMessagePrompts: wireMessages(options.messages, { includeContext: options.includeContext, cwd: options.cwd }),
|
|
38
|
-
chatModelUid: options.model,
|
|
39
|
-
requestType: "CHAT_MESSAGE_REQUEST_TYPE_CASCADE",
|
|
40
|
-
configuration: {
|
|
41
|
-
maxTokens: options.maxTokens,
|
|
42
|
-
numCompletions: 1,
|
|
43
|
-
maxNewlines: 400,
|
|
44
|
-
temperature: 1,
|
|
45
|
-
topK: 40,
|
|
46
|
-
topP: Math.fround(0.95),
|
|
47
|
-
},
|
|
48
|
-
trajectoryReference: {
|
|
49
|
-
trajectoryId: options.trajectoryId,
|
|
50
|
-
trajectoryType: "CORTEX_TRAJECTORY_TYPE_CASCADE",
|
|
51
|
-
...(!options.isAuxiliary && !options.isRecovery && options.messages.at(-1)?.source === "CHAT_MESSAGE_SOURCE_USER" ? { stepType: "CORTEX_STEP_TYPE_USER_INPUT" } : {}),
|
|
52
|
-
...(options.stepIndex ? { stepIndex: options.stepIndex } : {}),
|
|
53
|
-
},
|
|
54
|
-
cascadeId: options.cascadeId,
|
|
55
|
-
plannerMode: "CONVERSATIONAL_PLANNER_MODE_DEFAULT",
|
|
56
|
-
...(!options.isAuxiliary && options.executionId ? { executionId: options.executionId } : {}),
|
|
57
|
-
...(options.clientSessionId ? { clientSessionId: options.clientSessionId } : {}),
|
|
58
|
-
...(options.modelAssignmentJwt ? { modelAssignmentJwt: options.modelAssignmentJwt } : {}),
|
|
59
|
-
...(options.tools?.length ? { tools: options.tools } : {}),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Backends mark a finished turn differently: Anthropic text ends with MIN_LOG_PROB, Gemini tool calls with STOP_PATTERN,
|
|
64
|
-
// and OpenAI sends no stop reason (UNSPECIFIED is the proto3 default, so it is not on the wire).
|
|
65
|
-
const COMPLETE_STOP_REASONS = new Set(["STOP_REASON_STOP_PATTERN", "STOP_REASON_FUNCTION_CALL", "STOP_REASON_MIN_LOG_PROB", "STOP_REASON_UNSPECIFIED", ""]);
|
|
66
|
-
|
|
67
|
-
/** Attaches argument deltas without an ID to the call that started with the first ID. Ambiguous parallel deltas are rejected. */
|
|
68
|
-
export class ChatAccumulator {
|
|
69
|
-
readonly message: ChatMessage = { messageId: "", source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
|
|
70
|
-
readonly usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
|
|
71
|
-
readonly calls = new Map<string, ToolCall>();
|
|
72
|
-
private currentCall?: ToolCall;
|
|
73
|
-
private hasEnded = false;
|
|
74
|
-
private startedAt = performance.now();
|
|
75
|
-
private firstDeltaMs?: number;
|
|
76
|
-
private stopReason = "";
|
|
77
|
-
private usageReported = false;
|
|
78
|
-
private frameCount = 0;
|
|
79
|
-
private stopFrameIndex?: number;
|
|
80
|
-
private lastUsageFrameIndex?: number;
|
|
81
|
-
private idlessToolDeltaCount = 0;
|
|
82
|
-
private actualModelUid?: string;
|
|
83
|
-
private usageModelUid?: string;
|
|
84
|
-
|
|
85
|
-
accept(data: JsonObject, isEnd = false): void {
|
|
86
|
-
if (this.hasEnded) throw new Error("Cannot accumulate a response after EndStream.");
|
|
87
|
-
this.frameCount++;
|
|
88
|
-
if (isEnd) {
|
|
89
|
-
if (data.error) throw new Error("An error EndStream cannot be treated as success.");
|
|
90
|
-
this.hasEnded = true;
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (data.redact === true) throw new Error("The server redacted the response.");
|
|
94
|
-
if (typeof data.messageId === "string" && data.messageId) this.message.messageId = data.messageId;
|
|
95
|
-
if (data.thinkingRedacted === true) {
|
|
96
|
-
this.message.thinkingRedacted = true;
|
|
97
|
-
this.message.thinking = "";
|
|
98
|
-
}
|
|
99
|
-
if (typeof data.deltaText === "string") this.message.prompt += data.deltaText;
|
|
100
|
-
if (typeof data.deltaThinking === "string" && !this.message.thinkingRedacted) {
|
|
101
|
-
this.message.thinking = (this.message.thinking ?? "") + data.deltaThinking;
|
|
102
|
-
}
|
|
103
|
-
if (typeof data.deltaSignature === "string") this.message.signature = (this.message.signature ?? "") + data.deltaSignature;
|
|
104
|
-
for (const [source, target] of [
|
|
105
|
-
["deltaSignatureType", "signatureType"], ["geminiThoughtSignature", "geminiThoughtSignature"],
|
|
106
|
-
["outputId", "outputId"], ["thinkingId", "thinkingId"], ["phase", "phase"],
|
|
107
|
-
] as const) {
|
|
108
|
-
if (typeof data[source] === "string") this.message[target] = data[source];
|
|
109
|
-
}
|
|
110
|
-
if (typeof data.actualModelUid === "string") this.actualModelUid = data.actualModelUid;
|
|
111
|
-
if (data.deltaToolCalls !== undefined) {
|
|
112
|
-
if (!Array.isArray(data.deltaToolCalls)) throw new Error("deltaToolCalls must be an array.");
|
|
113
|
-
for (const value of data.deltaToolCalls) {
|
|
114
|
-
const delta = object(value, "tool delta");
|
|
115
|
-
const id = typeof delta.id === "string" && delta.id ? delta.id : undefined;
|
|
116
|
-
if (id) {
|
|
117
|
-
let call = this.calls.get(id);
|
|
118
|
-
if (!call) {
|
|
119
|
-
call = { id, name: "", argumentsJson: "" };
|
|
120
|
-
this.calls.set(id, call);
|
|
121
|
-
}
|
|
122
|
-
this.currentCall = call;
|
|
123
|
-
} else {
|
|
124
|
-
this.idlessToolDeltaCount++;
|
|
125
|
-
if (data.deltaToolCalls.length !== 1 || !this.currentCall || this.calls.size > 1) {
|
|
126
|
-
throw new Error("Cannot attach a delta without a tool ID to a specific call.");
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
const call = this.currentCall!;
|
|
130
|
-
if (delta.isCustomToolCall) throw new Error("Grammar-based custom tools are not supported.");
|
|
131
|
-
if (delta.invalidJsonErr || delta.invalidJsonStr) throw new Error("The server reported a tool argument JSON error.");
|
|
132
|
-
if (typeof delta.name === "string") call.name += delta.name;
|
|
133
|
-
if (typeof delta.argumentsJson === "string") call.argumentsJson += delta.argumentsJson;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (data.deltaText || data.deltaThinking || data.deltaToolCalls) {
|
|
137
|
-
this.firstDeltaMs ??= performance.now() - this.startedAt;
|
|
138
|
-
}
|
|
139
|
-
if (typeof data.stopReason === "string" && data.stopReason) {
|
|
140
|
-
this.stopReason = data.stopReason;
|
|
141
|
-
this.stopFrameIndex = this.frameCount;
|
|
142
|
-
}
|
|
143
|
-
if (data.usage !== undefined) {
|
|
144
|
-
const usage = object(data.usage, "usage");
|
|
145
|
-
if (typeof usage.modelUid === "string") this.usageModelUid = usage.modelUid;
|
|
146
|
-
for (const [source, target] of [
|
|
147
|
-
["inputTokens", "input"], ["outputTokens", "output"],
|
|
148
|
-
["cacheReadTokens", "cacheRead"], ["cacheWriteTokens", "cacheWrite"],
|
|
149
|
-
] as const) {
|
|
150
|
-
if (usage[source] !== undefined) {
|
|
151
|
-
this.usage[target] = tokenCount(usage[source], source);
|
|
152
|
-
this.usageReported = true;
|
|
153
|
-
this.lastUsageFrameIndex = this.frameCount;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
this.usage.totalTokens = this.usage.input + this.usage.output + this.usage.cacheRead + this.usage.cacheWrite;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
finish(): ChatResult {
|
|
161
|
-
if (!this.hasEnded) throw new Error("No successful EndStream.");
|
|
162
|
-
if (!COMPLETE_STOP_REASONS.has(this.stopReason) && this.stopReason !== "STOP_REASON_MAX_TOKENS") {
|
|
163
|
-
throw new Error(`stopReason cannot be treated as success: ${this.stopReason || "(none)"}`);
|
|
164
|
-
}
|
|
165
|
-
if (this.calls.size > 0) {
|
|
166
|
-
this.message.toolCalls = [...this.calls.values()];
|
|
167
|
-
for (const call of this.message.toolCalls) {
|
|
168
|
-
requiredString(call.name, "tool name");
|
|
169
|
-
let args: unknown;
|
|
170
|
-
try { args = JSON.parse(call.argumentsJson); }
|
|
171
|
-
catch { throw new Error(`Arguments of tool ${call.name} are not valid JSON.`); }
|
|
172
|
-
object(args, "tool arguments");
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (this.stopReason === "STOP_REASON_FUNCTION_CALL" && !this.calls.size) {
|
|
176
|
-
throw new Error("The response ended with FUNCTION_CALL but has no tool call.");
|
|
177
|
-
}
|
|
178
|
-
// Tool use is read from the calls themselves; only calls cut off at the output limit are rejected.
|
|
179
|
-
if (this.calls.size && !COMPLETE_STOP_REASONS.has(this.stopReason)) {
|
|
180
|
-
throw new Error("Tool calls and the stop reason do not match.");
|
|
181
|
-
}
|
|
182
|
-
this.message.messageId ||= randomUUID();
|
|
183
|
-
return {
|
|
184
|
-
message: this.message,
|
|
185
|
-
stopReason: this.stopReason,
|
|
186
|
-
usage: this.usage,
|
|
187
|
-
usageReported: this.usageReported,
|
|
188
|
-
frameCount: this.frameCount,
|
|
189
|
-
stopFrameIndex: this.stopFrameIndex,
|
|
190
|
-
lastUsageFrameIndex: this.lastUsageFrameIndex,
|
|
191
|
-
idlessToolDeltaCount: this.idlessToolDeltaCount,
|
|
192
|
-
durationMs: Math.round(performance.now() - this.startedAt),
|
|
193
|
-
firstDeltaMs: this.firstDeltaMs === undefined ? undefined : Math.round(this.firstDeltaMs),
|
|
194
|
-
actualModelUid: this.actualModelUid,
|
|
195
|
-
...(this.usageModelUid ? { usageModelUid: this.usageModelUid } : {}),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export async function streamChat(
|
|
201
|
-
client: ConnectClient,
|
|
202
|
-
request: JsonObject,
|
|
203
|
-
options: { signal?: AbortSignal; onText?: (delta: string) => void; stage?: string; onDispatched?: () => void } = {},
|
|
204
|
-
): Promise<ChatResult> {
|
|
205
|
-
const accumulator = new ChatAccumulator();
|
|
206
|
-
for await (const frame of client.stream(CHAT_METHOD, request, options.signal, { stage: options.stage, onDispatched: options.onDispatched })) {
|
|
207
|
-
accumulator.accept(frame.data, frame.isEnd);
|
|
208
|
-
if (!frame.isEnd && typeof frame.data.deltaText === "string") options.onText?.(frame.data.deltaText);
|
|
209
|
-
}
|
|
210
|
-
const result = accumulator.finish();
|
|
211
|
-
// Field 22 records the model of the generation request, not actualModelUid/usage.modelUid.
|
|
212
|
-
if (typeof request.chatModelUid === "string") result.message.modelUid = request.chatModelUid;
|
|
213
|
-
return result;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Raw thinking and signatures never go to logs or reports. They are only resent inside requests. */
|
|
217
|
-
export function summarizeChat(result: ChatResult): JsonObject {
|
|
218
|
-
return {
|
|
219
|
-
text: result.message.prompt,
|
|
220
|
-
stopReason: result.stopReason,
|
|
221
|
-
usage: result.usage,
|
|
222
|
-
usageReported: result.usageReported,
|
|
223
|
-
toolCalls: result.message.toolCalls ?? [],
|
|
224
|
-
thinkingChars: result.message.thinking?.length ?? 0,
|
|
225
|
-
hasSignature: Boolean(result.message.signature || result.message.geminiThoughtSignature),
|
|
226
|
-
frameCount: result.frameCount,
|
|
227
|
-
stopFrameIndex: result.stopFrameIndex,
|
|
228
|
-
lastUsageFrameIndex: result.lastUsageFrameIndex,
|
|
229
|
-
idlessToolDeltaCount: result.idlessToolDeltaCount,
|
|
230
|
-
durationMs: result.durationMs,
|
|
231
|
-
firstDeltaMs: result.firstDeltaMs,
|
|
232
|
-
actualModelUid: result.actualModelUid,
|
|
233
|
-
};
|
|
234
|
-
}
|