pi-advisor-flow 0.6.0 → 0.8.0
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/CHANGELOG.md +34 -17
- package/README.md +10 -8
- package/dist/index.js +2852 -868
- package/extensions/index.ts +2 -1
- package/package.json +55 -40
- package/src/attachments.ts +11 -10
- package/src/commands/activation-preparation.ts +1 -0
- package/src/commands/activation.ts +2 -0
- package/src/commands/lifecycle.ts +1 -0
- package/src/commands/manual-command.ts +7 -1
- package/src/commands/manual-consultation.ts +103 -79
- package/src/commands/manual-progress.ts +7 -5
- package/src/commands/model-commands.ts +1 -1
- package/src/commands/model-options.ts +14 -3
- package/src/commands/model-picker.ts +1 -0
- package/src/commands/registration.ts +1 -0
- package/src/commands/renderers.ts +67 -55
- package/src/commands/runtime.ts +16 -14
- package/src/commands/settings-commands.ts +10 -4
- package/src/commands/settings-persistence.ts +64 -2
- package/src/commands/types.ts +2 -0
- package/src/commands.ts +0 -1
- package/src/config/args.ts +1 -1
- package/src/config/defaults.ts +83 -1
- package/src/config/schema.ts +152 -15
- package/src/config/state.ts +80 -2
- package/src/config/storage.ts +38 -22
- package/src/config/types.ts +45 -3
- package/src/config/validation.ts +33 -21
- package/src/config.ts +3 -0
- package/src/content-utils.ts +24 -4
- package/src/conversation.ts +16 -15
- package/src/git.ts +10 -3
- package/src/herdr-block.ts +83 -0
- package/src/herdr-shared.ts +108 -0
- package/src/herdr.ts +30 -192
- package/src/jev/client.ts +338 -0
- package/src/jev/failure.ts +19 -0
- package/src/jev/key-store.ts +267 -0
- package/src/jev/ledger.ts +228 -0
- package/src/jev/questions.ts +122 -0
- package/src/jev/state.ts +50 -0
- package/src/jev/transport.ts +63 -0
- package/src/model-stream.ts +35 -37
- package/src/outcomes.ts +20 -12
- package/src/pi-settings.ts +4 -9
- package/src/preferences.ts +4 -2
- package/src/redaction.ts +10 -12
- package/src/scout-context.ts +59 -60
- package/src/scout-curation.ts +4 -13
- package/src/scout-groups.ts +178 -175
- package/src/scout-protocol.ts +74 -70
- package/src/scout-reconstruct.ts +1 -1
- package/src/scout.ts +115 -73
- package/src/session-state.ts +148 -39
- package/src/tool-result-cap.ts +18 -17
- package/src/tools/consult-context.ts +7 -15
- package/src/tools/consultation.ts +14 -4
- package/src/tools/gate-policy.ts +1 -0
- package/src/tools/gate-protocol.ts +36 -23
- package/src/tools/jev-filter.ts +204 -0
- package/src/tools/jev-turn-gate.ts +203 -0
- package/src/tools/loop-gate.ts +32 -20
- package/src/tools/model-access.ts +46 -0
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/prompts.ts +20 -25
- package/src/tools/register-ask-advisor.ts +73 -17
- package/src/tools/register-lifecycle.ts +22 -0
- package/src/tools/register-outcome.ts +2 -0
- package/src/tools/register-renderers.ts +149 -73
- package/src/tools/registration.ts +15 -0
- package/src/tools/render-advisor-result.ts +77 -35
- package/src/tools/render-common.ts +16 -19
- package/src/tools/scout-status.ts +42 -27
- package/src/tools/types.ts +11 -0
- package/src/tools.ts +0 -1
- package/src/ui/jev-setup-submenu.ts +350 -0
- package/src/ui/manual-dialog-render.ts +1 -0
- package/src/ui/manual-dialog.ts +39 -43
- package/src/ui/masked-input.ts +62 -0
- package/src/ui/model-multi-selector.ts +9 -0
- package/src/ui/model-selector-adapter.ts +31 -0
- package/src/ui/model-selector.ts +14 -161
- package/src/ui/searchable-model-list.ts +230 -0
- package/src/ui/settings-formatting.ts +24 -14
- package/src/ui/settings-items.ts +200 -2
- package/src/ui/settings-list-adapter.ts +15 -12
- package/src/ui/settings-mutations.ts +169 -47
- package/src/ui/settings-selector.ts +18 -9
- package/src/ui/text-setting-submenu.ts +3 -6
- package/src/ui/types.ts +25 -0
- package/src/ui.ts +0 -1
- package/src/usage.ts +29 -20
package/src/herdr.ts
CHANGED
|
@@ -1,108 +1,37 @@
|
|
|
1
|
-
import net from "node:net";
|
|
2
1
|
import { getAdvisorSettings } from "./config/state.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
agent: "pi";
|
|
25
|
-
applies_to_source: string;
|
|
26
|
-
state_labels?: { working?: string; blocked?: string };
|
|
27
|
-
clear_state_labels?: true;
|
|
28
|
-
seq: number;
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
export interface HerdrNotificationRequest {
|
|
32
|
-
id: string;
|
|
33
|
-
method: typeof HERDR_NOTIFICATION_METHOD;
|
|
34
|
-
params: {
|
|
35
|
-
title: string;
|
|
36
|
-
body: string;
|
|
37
|
-
position: "top-left";
|
|
38
|
-
sound: "request";
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
|
|
42
|
-
type Report = (request: HerdrRequest) => void;
|
|
43
|
-
|
|
44
|
-
// Herdr drops socket reports from other sources, so the blocked state must be
|
|
45
|
-
// signalled through the in-process pi event bus its integration listens on.
|
|
46
|
-
type BlockedEmitter = (active: boolean, label: string) => void;
|
|
47
|
-
let emitBlocked: BlockedEmitter | undefined;
|
|
48
|
-
|
|
49
|
-
export const setHerdrBlockedEmitter = (emitter: BlockedEmitter | undefined) => {
|
|
50
|
-
emitBlocked = emitter;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const safeEmitBlocked = (active: boolean, label = "Advisor blocked") => {
|
|
54
|
-
try {
|
|
55
|
-
emitBlocked?.(active, label);
|
|
56
|
-
} catch {
|
|
57
|
-
/* Herdr is optional. */
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const isControlCharacter = (character: string) =>
|
|
62
|
-
character <= "\u001f" || character === "\u007f";
|
|
63
|
-
|
|
64
|
-
const cleanNotification = (value: string, max: number) =>
|
|
65
|
-
[...redactSecrets(value)]
|
|
66
|
-
.map((character) => (isControlCharacter(character) ? " " : character))
|
|
67
|
-
.join("")
|
|
68
|
-
.replace(/\s+/g, " ")
|
|
69
|
-
.trim()
|
|
70
|
-
.slice(0, max);
|
|
71
|
-
|
|
72
|
-
export const createHerdrNotificationRequest = (
|
|
73
|
-
title: string,
|
|
74
|
-
body: string
|
|
75
|
-
): HerdrNotificationRequest => ({
|
|
76
|
-
id: `${NOTIFICATION_SOURCE}:${nextSequence()}`,
|
|
77
|
-
method: HERDR_NOTIFICATION_METHOD,
|
|
2
|
+
import { HerdrAdvisorBlock } from "./herdr-block.ts";
|
|
3
|
+
import type { HerdrMetadataRequest, Report } from "./herdr-shared.ts";
|
|
4
|
+
import {
|
|
5
|
+
createHerdrNotificationRequest,
|
|
6
|
+
HERDR_PI_SOURCE,
|
|
7
|
+
nextSequence,
|
|
8
|
+
sendToHerdr,
|
|
9
|
+
SOURCE,
|
|
10
|
+
} from "./herdr-shared.ts";
|
|
11
|
+
|
|
12
|
+
export { HerdrAdvisorBlock } from "./herdr-block.ts";
|
|
13
|
+
export {
|
|
14
|
+
createHerdrNotificationRequest,
|
|
15
|
+
type HerdrMetadataRequest,
|
|
16
|
+
type HerdrNotificationRequest,
|
|
17
|
+
setHerdrBlockedEmitter,
|
|
18
|
+
} from "./herdr-shared.ts";
|
|
19
|
+
|
|
20
|
+
const metadataRequest = (clear: boolean): HerdrMetadataRequest => ({
|
|
21
|
+
id: `${SOURCE}:${nextSequence()}`,
|
|
22
|
+
method: "pane.report_metadata",
|
|
78
23
|
params: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
24
|
+
agent: "pi",
|
|
25
|
+
applies_to_source: HERDR_PI_SOURCE,
|
|
26
|
+
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
27
|
+
source: SOURCE,
|
|
28
|
+
...(clear
|
|
29
|
+
? { clear_state_labels: true }
|
|
30
|
+
: { state_labels: { working: "seeking advice" } }),
|
|
31
|
+
seq: nextSequence(),
|
|
83
32
|
},
|
|
84
33
|
});
|
|
85
34
|
|
|
86
|
-
const sendToHerdr: Report = (request) => {
|
|
87
|
-
if (process.env.HERDR_ENV !== "1") {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const paneId = process.env.HERDR_PANE_ID;
|
|
91
|
-
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
92
|
-
if (!(paneId && socketPath)) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const endpoint =
|
|
96
|
-
process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
97
|
-
const socket = net.createConnection(endpoint);
|
|
98
|
-
const timeout = setTimeout(() => socket.destroy(), 500);
|
|
99
|
-
timeout.unref?.();
|
|
100
|
-
socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
101
|
-
socket.once("data", () => socket.destroy());
|
|
102
|
-
socket.once("error", () => socket.destroy());
|
|
103
|
-
socket.once("close", () => clearTimeout(timeout));
|
|
104
|
-
};
|
|
105
|
-
|
|
106
35
|
export class HerdrAdvisorActivity {
|
|
107
36
|
#activeConsultations = 0;
|
|
108
37
|
private readonly report: Report;
|
|
@@ -146,98 +75,7 @@ export class HerdrAdvisorActivity {
|
|
|
146
75
|
|
|
147
76
|
private safeReport(clear: boolean) {
|
|
148
77
|
try {
|
|
149
|
-
this.report(
|
|
150
|
-
} catch {
|
|
151
|
-
/* Herdr is optional. */
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private request(clear: boolean): HerdrMetadataRequest {
|
|
156
|
-
return {
|
|
157
|
-
id: `${SOURCE}:${nextSequence()}`,
|
|
158
|
-
method: "pane.report_metadata",
|
|
159
|
-
params: {
|
|
160
|
-
agent: "pi",
|
|
161
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
162
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
163
|
-
source: SOURCE,
|
|
164
|
-
...(clear
|
|
165
|
-
? { clear_state_labels: true }
|
|
166
|
-
: { state_labels: { working: "seeking advice" } }),
|
|
167
|
-
seq: nextSequence(),
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export class HerdrAdvisorBlock {
|
|
174
|
-
#blocked = false;
|
|
175
|
-
private readonly report: Report;
|
|
176
|
-
private readonly enabled: () => boolean;
|
|
177
|
-
|
|
178
|
-
constructor(
|
|
179
|
-
report: Report = sendToHerdr,
|
|
180
|
-
enabled: () => boolean = () => true
|
|
181
|
-
) {
|
|
182
|
-
this.report = report;
|
|
183
|
-
this.enabled = enabled;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
set(reason: string) {
|
|
187
|
-
if (!this.enabled()) {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const label = cleanNotification(reason, 200);
|
|
191
|
-
const wasBlocked: boolean = this.#blocked;
|
|
192
|
-
if (!wasBlocked) {
|
|
193
|
-
// The listener refcounts, so only the false → true edge may emit.
|
|
194
|
-
safeEmitBlocked(true, label);
|
|
195
|
-
}
|
|
196
|
-
this.#blocked = true;
|
|
197
|
-
this.safeReport({ blocked: label });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
clear() {
|
|
201
|
-
const wasBlocked: boolean = this.#blocked;
|
|
202
|
-
this.#blocked = false;
|
|
203
|
-
if (!wasBlocked) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
// Clearing previously reported state is a de-escalation and must still be
|
|
207
|
-
// delivered if integration was disabled after the block was reported.
|
|
208
|
-
safeEmitBlocked(false);
|
|
209
|
-
try {
|
|
210
|
-
this.report({
|
|
211
|
-
id: `${BLOCK_SOURCE}:${nextSequence()}`,
|
|
212
|
-
method: "pane.report_metadata",
|
|
213
|
-
params: {
|
|
214
|
-
agent: "pi",
|
|
215
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
216
|
-
clear_state_labels: true,
|
|
217
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
218
|
-
seq: nextSequence(),
|
|
219
|
-
source: BLOCK_SOURCE,
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
} catch {
|
|
223
|
-
/* Herdr is optional. */
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private safeReport(labels: { blocked: string }) {
|
|
228
|
-
try {
|
|
229
|
-
this.report({
|
|
230
|
-
id: `${BLOCK_SOURCE}:${nextSequence()}`,
|
|
231
|
-
method: "pane.report_metadata",
|
|
232
|
-
params: {
|
|
233
|
-
agent: "pi",
|
|
234
|
-
applies_to_source: HERDR_PI_SOURCE,
|
|
235
|
-
pane_id: process.env.HERDR_PANE_ID ?? "",
|
|
236
|
-
seq: nextSequence(),
|
|
237
|
-
source: BLOCK_SOURCE,
|
|
238
|
-
state_labels: labels,
|
|
239
|
-
},
|
|
240
|
-
});
|
|
78
|
+
this.report(metadataRequest(clear));
|
|
241
79
|
} catch {
|
|
242
80
|
/* Herdr is optional. */
|
|
243
81
|
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { EntryType, Fetch, Questions } from "@typesafe-ai/sdk";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
advisorJevModelRef,
|
|
5
|
+
advisorJevPricePerMtokRef,
|
|
6
|
+
advisorJevTimeoutMsRef,
|
|
7
|
+
} from "../config/state.ts";
|
|
8
|
+
import { isNumber, isRecord, isRecordOf, isString } from "../content-utils.ts";
|
|
9
|
+
import type { JsonValue, RecordValue } from "../content-utils.ts";
|
|
10
|
+
import { redactSecrets } from "../redaction.ts";
|
|
11
|
+
import { JevFailureError } from "./failure.ts";
|
|
12
|
+
import type { JevErrorCategory } from "./failure.ts";
|
|
13
|
+
import type { JevCredentials, JevTransportKind } from "./transport.ts";
|
|
14
|
+
|
|
15
|
+
export type { JevErrorCategory } from "./failure.ts";
|
|
16
|
+
export { JevFailureError as JevFailure } from "./failure.ts";
|
|
17
|
+
|
|
18
|
+
export interface JevUsage {
|
|
19
|
+
cost: number;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JevAskResult {
|
|
25
|
+
answers: RecordValue;
|
|
26
|
+
model: string;
|
|
27
|
+
usage: JevUsage;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JevClientOptions {
|
|
31
|
+
apiKey: string;
|
|
32
|
+
fetch?: Fetch;
|
|
33
|
+
model: string;
|
|
34
|
+
pricePerMtok?: number;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
transport: JevTransportKind;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ENDPOINTS: Record<JevTransportKind, string> = {
|
|
40
|
+
openrouter: "https://openrouter.ai/api/alpha/decisions",
|
|
41
|
+
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const range = (from: number, to: number): number[] =>
|
|
45
|
+
Array.from({ length: to - from + 1 }, (_, index) => from + index);
|
|
46
|
+
|
|
47
|
+
const RETRYABLE_STATUSES = new Set([408, 429, ...range(500, 599)]);
|
|
48
|
+
const RETRY_BACKOFF_MS = 250;
|
|
49
|
+
const MAX_ATTEMPTS = 2;
|
|
50
|
+
|
|
51
|
+
const finiteTokens = (value: JsonValue | undefined): number =>
|
|
52
|
+
isNumber(value) && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
53
|
+
|
|
54
|
+
const errorDetail = (error: JsonValue | undefined): string => {
|
|
55
|
+
if (isString(error)) {
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
if (isRecordOf(error) && "message" in error) {
|
|
59
|
+
return String(error.message);
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const statusCategory = (status: number): JevErrorCategory => {
|
|
65
|
+
if (status === 401 || status === 403) {
|
|
66
|
+
return "auth";
|
|
67
|
+
}
|
|
68
|
+
if (status === 400 || status === 422) {
|
|
69
|
+
return "malformed";
|
|
70
|
+
}
|
|
71
|
+
return "error";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isObjectLike = <Value>(value: Value): value is Value & object =>
|
|
75
|
+
typeof value === "object";
|
|
76
|
+
|
|
77
|
+
const openRouterModelId = (model: string) =>
|
|
78
|
+
model.includes("/") ? model : `~typesafe/${model}`;
|
|
79
|
+
|
|
80
|
+
interface AttemptOutcome {
|
|
81
|
+
answers?: RecordValue;
|
|
82
|
+
failure?: JevFailureError;
|
|
83
|
+
model?: string;
|
|
84
|
+
retryable?: boolean;
|
|
85
|
+
usage?: { input_tokens?: JsonValue; output_tokens?: JsonValue };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const connectionFailure = <E>(error: E) => {
|
|
89
|
+
const message = redactSecrets(
|
|
90
|
+
error instanceof Error ? error.message : String(error)
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
failure: new JevFailureError(
|
|
94
|
+
"network",
|
|
95
|
+
`Jev connection failed: ${message}`
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> =>
|
|
101
|
+
// oxlint-disable-next-line promise/avoid-new -- unref-ed abortable retry timer cannot be expressed with async/await.
|
|
102
|
+
new Promise((resolve) => {
|
|
103
|
+
const timer = setTimeout(resolve, ms);
|
|
104
|
+
timer.unref?.();
|
|
105
|
+
signal.addEventListener(
|
|
106
|
+
"abort",
|
|
107
|
+
() => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
},
|
|
111
|
+
{ once: true }
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** One systemone client for both transports with a total wall deadline so
|
|
116
|
+
* retries can never stall a tool call; errors are classified and redacted on
|
|
117
|
+
* every path. */
|
|
118
|
+
export class JevClient {
|
|
119
|
+
readonly #apiKey: string;
|
|
120
|
+
readonly #endpoint: string;
|
|
121
|
+
readonly #fetch: Fetch;
|
|
122
|
+
readonly #model: string;
|
|
123
|
+
readonly #pricePerMtok: number | undefined;
|
|
124
|
+
readonly #timeoutMs: number;
|
|
125
|
+
|
|
126
|
+
constructor({
|
|
127
|
+
apiKey,
|
|
128
|
+
fetch,
|
|
129
|
+
model,
|
|
130
|
+
pricePerMtok,
|
|
131
|
+
timeoutMs,
|
|
132
|
+
transport,
|
|
133
|
+
}: JevClientOptions) {
|
|
134
|
+
this.#apiKey = apiKey;
|
|
135
|
+
this.#endpoint = ENDPOINTS[transport];
|
|
136
|
+
// SAFETY: the bound global fetch satisfies the SDK Fetch signature; binding keeps the receiver correct.
|
|
137
|
+
this.#fetch = fetch ?? (globalThis.fetch.bind(globalThis) as Fetch);
|
|
138
|
+
this.#model = transport === "openrouter" ? openRouterModelId(model) : model;
|
|
139
|
+
this.#pricePerMtok = pricePerMtok;
|
|
140
|
+
this.#timeoutMs = timeoutMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async ask(
|
|
144
|
+
state: EntryType,
|
|
145
|
+
questions: Questions,
|
|
146
|
+
signal?: AbortSignal
|
|
147
|
+
): Promise<JevAskResult> {
|
|
148
|
+
const deadline = new AbortController();
|
|
149
|
+
const abortFromCaller = () => deadline.abort(signal?.reason);
|
|
150
|
+
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
151
|
+
if (signal?.aborted) {
|
|
152
|
+
abortFromCaller();
|
|
153
|
+
}
|
|
154
|
+
let deadlineHit = false;
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
deadlineHit = true;
|
|
157
|
+
deadline.abort(new Error("Jev wall-time budget elapsed"));
|
|
158
|
+
}, this.#timeoutMs);
|
|
159
|
+
timer.unref?.();
|
|
160
|
+
const body = JSON.stringify({
|
|
161
|
+
model: this.#model,
|
|
162
|
+
questions,
|
|
163
|
+
state,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
let outcome: AttemptOutcome = {};
|
|
167
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
168
|
+
// The retry loop is bounded to one backoff by the deadline controller.
|
|
169
|
+
outcome = await this.#attempt(body, deadline.signal);
|
|
170
|
+
if (!outcome.retryable || attempt >= MAX_ATTEMPTS) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
await sleepWithAbort(deadline.signal, RETRY_BACKOFF_MS);
|
|
174
|
+
if (deadline.signal.aborted) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return this.#settle(outcome, deadlineHit, signal);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (signal?.aborted && !deadlineHit) {
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (error instanceof JevFailureError) {
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const message = redactSecrets(
|
|
187
|
+
error instanceof Error ? error.message : String(error)
|
|
188
|
+
);
|
|
189
|
+
throw deadlineHit
|
|
190
|
+
? new JevFailureError(
|
|
191
|
+
"timeout",
|
|
192
|
+
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
193
|
+
)
|
|
194
|
+
: new JevFailureError("error", message);
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#settle(
|
|
202
|
+
outcome: AttemptOutcome,
|
|
203
|
+
deadlineHit: boolean,
|
|
204
|
+
signal: AbortSignal | undefined
|
|
205
|
+
): JevAskResult {
|
|
206
|
+
if (outcome.answers) {
|
|
207
|
+
return this.#result(outcome);
|
|
208
|
+
}
|
|
209
|
+
if (deadlineHit && outcome.failure?.category !== "auth") {
|
|
210
|
+
throw new JevFailureError(
|
|
211
|
+
"timeout",
|
|
212
|
+
`Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (outcome.failure) {
|
|
216
|
+
throw outcome.failure;
|
|
217
|
+
}
|
|
218
|
+
if (signal?.aborted) {
|
|
219
|
+
throw new Error("Jev call aborted by the caller.");
|
|
220
|
+
}
|
|
221
|
+
throw new JevFailureError("error", "Jev call failed.");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #attempt(body: string, signal: AbortSignal): Promise<AttemptOutcome> {
|
|
225
|
+
if (signal.aborted) {
|
|
226
|
+
return { retryable: false };
|
|
227
|
+
}
|
|
228
|
+
let response: Response;
|
|
229
|
+
try {
|
|
230
|
+
response = await this.#fetch(this.#endpoint, {
|
|
231
|
+
body,
|
|
232
|
+
headers: {
|
|
233
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
234
|
+
"content-type": "application/json",
|
|
235
|
+
},
|
|
236
|
+
method: "POST",
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (signal.aborted) {
|
|
241
|
+
return { retryable: false };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
retryable: true,
|
|
245
|
+
...connectionFailure(error),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (response.ok) {
|
|
249
|
+
return this.#parseSuccess(response);
|
|
250
|
+
}
|
|
251
|
+
const failure = await this.#failureFromStatus(response);
|
|
252
|
+
return {
|
|
253
|
+
failure,
|
|
254
|
+
retryable: RETRYABLE_STATUSES.has(response.status),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #failureFromStatus(response: Response): Promise<JevFailureError> {
|
|
259
|
+
let detail = "";
|
|
260
|
+
try {
|
|
261
|
+
const parsed: unknown = await response.json();
|
|
262
|
+
const error = isRecord(parsed) ? parsed.error : undefined;
|
|
263
|
+
detail = errorDetail(error);
|
|
264
|
+
} catch {
|
|
265
|
+
detail = "";
|
|
266
|
+
}
|
|
267
|
+
const message = redactSecrets(
|
|
268
|
+
`Jev ${this.#transportLabel()} request failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}.`
|
|
269
|
+
);
|
|
270
|
+
return new JevFailureError(statusCategory(response.status), message);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#transportLabel(): string {
|
|
274
|
+
return this.#endpoint === ENDPOINTS.openrouter ? "OpenRouter" : "TypeSafe";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async #parseSuccess(response: Response): Promise<AttemptOutcome> {
|
|
278
|
+
let parsed: unknown;
|
|
279
|
+
try {
|
|
280
|
+
parsed = await response.json();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return {
|
|
283
|
+
failure: new JevFailureError(
|
|
284
|
+
"malformed",
|
|
285
|
+
`Jev response was not JSON: ${redactSecrets(error instanceof Error ? error.message : String(error))}`
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!isRecord(parsed) || !parsed.answers || !isObjectLike(parsed.answers)) {
|
|
290
|
+
return {
|
|
291
|
+
failure: new JevFailureError(
|
|
292
|
+
"malformed",
|
|
293
|
+
"Jev response did not include an answers object."
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
// SAFETY: the response contract guarantees an answers object; the shape is re-validated per key by consumers.
|
|
299
|
+
answers: parsed.answers as RecordValue,
|
|
300
|
+
model: isString(parsed.model) ? parsed.model : this.#model,
|
|
301
|
+
usage: isRecordOf(parsed.usage)
|
|
302
|
+
? {
|
|
303
|
+
input_tokens: parsed.usage.input_tokens,
|
|
304
|
+
output_tokens: parsed.usage.output_tokens,
|
|
305
|
+
}
|
|
306
|
+
: undefined,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#result(outcome: AttemptOutcome): JevAskResult {
|
|
311
|
+
const { usage } = outcome;
|
|
312
|
+
const inputTokens = finiteTokens(usage?.input_tokens);
|
|
313
|
+
const outputTokens = finiteTokens(usage?.output_tokens);
|
|
314
|
+
const price = this.#pricePerMtok ?? advisorJevPricePerMtokRef;
|
|
315
|
+
return {
|
|
316
|
+
answers: outcome.answers ?? {},
|
|
317
|
+
model: outcome.model ?? this.#model,
|
|
318
|
+
usage: {
|
|
319
|
+
cost: (inputTokens / 1_000_000) * price,
|
|
320
|
+
inputTokens,
|
|
321
|
+
outputTokens,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A client wired from resolved credentials; reads the live Jev settings per call. */
|
|
328
|
+
export const jevClientFromCredentials = (
|
|
329
|
+
credentials: JevCredentials,
|
|
330
|
+
fetch?: Fetch
|
|
331
|
+
): JevClient =>
|
|
332
|
+
new JevClient({
|
|
333
|
+
apiKey: credentials.apiKey,
|
|
334
|
+
fetch,
|
|
335
|
+
model: advisorJevModelRef,
|
|
336
|
+
timeoutMs: advisorJevTimeoutMsRef,
|
|
337
|
+
transport: credentials.transport,
|
|
338
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type JevErrorCategory =
|
|
2
|
+
| "auth"
|
|
3
|
+
| "timeout"
|
|
4
|
+
| "network"
|
|
5
|
+
| "malformed"
|
|
6
|
+
| "error";
|
|
7
|
+
|
|
8
|
+
/** A classified, redacted Jev failure; never carries key material. */
|
|
9
|
+
export class JevFailureError extends Error {
|
|
10
|
+
readonly category: JevErrorCategory;
|
|
11
|
+
|
|
12
|
+
constructor(category: JevErrorCategory, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "JevFailureError";
|
|
15
|
+
this.category = category;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { JevFailureError as JevFailure };
|