@evoclock/pi-agentic-driver 0.9.1 → 0.9.3
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/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +116 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +14 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +654 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Provider architecture (§7, review fix #2, authoritative owner decision):
|
|
5
|
+
//
|
|
6
|
+
// 1. typesafe-direct — THE SOLE PROVIDER (owner decision): the official
|
|
7
|
+
// @typesafe-ai/sdk (`TypeSafeClient.systemOne`)
|
|
8
|
+
// with the owner's direct API key from the macOS
|
|
9
|
+
// Keychain (account `typesafe-ai`, service
|
|
10
|
+
// `Typesafe AI`). The Vercel AI Gateway and the
|
|
11
|
+
// merge gateway are DROPPED — there is no fallback
|
|
12
|
+
// transport, and an unconfigured TypeSafe Direct
|
|
13
|
+
// fails closed with the existing error taxonomy.
|
|
14
|
+
//
|
|
15
|
+
// Invariants enforced here:
|
|
16
|
+
// - The question TEXT is always sent to the provider, verbatim, first.
|
|
17
|
+
// Optional framing instructions are appended in an unambiguous,
|
|
18
|
+
// clearly-delimited block (review fix #1). Missing/blank question text
|
|
19
|
+
// is impossible past schema enforcement (validation_error upstream) —
|
|
20
|
+
// no synthetic `question[i]` placeholder is ever constructed.
|
|
21
|
+
// - Question semantics are IDENTICAL across adapters: every adapter maps
|
|
22
|
+
// the same transport contract (question text + framing + criteria) to
|
|
23
|
+
// its provider's helpers (TypeSafe `noul`/`choice`/`score`; AI SDK
|
|
24
|
+
// evaluation questions). Mapping tests assert shape equality.
|
|
25
|
+
// - Exactly ONE identical-request failover attempt on eligible transport
|
|
26
|
+
// failures (timeout / unavailable / credential). No failover on
|
|
27
|
+
// validation, schema, or budget errors.
|
|
28
|
+
// - Failed-attempt usage is never lost: every terminal chain failure
|
|
29
|
+
// carries ALL accumulated attempt usage (including the terminal
|
|
30
|
+
// attempt) so the Evaluator can charge it exactly once (review fix #12).
|
|
31
|
+
// - SDK-level retries are disabled (maxRetries = 0) so janus owns the
|
|
32
|
+
// retry policy and budget accounting (review fix #12).
|
|
33
|
+
// - Public error messages are FIXED strings; provider/SDK/internal error
|
|
34
|
+
// text never reaches HTTP callers (review fix #5).
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
APIConnectionError,
|
|
38
|
+
APIError,
|
|
39
|
+
APITimeoutError,
|
|
40
|
+
APIUserAbortError,
|
|
41
|
+
TypeSafeClient,
|
|
42
|
+
TypeSafeError,
|
|
43
|
+
choice as tsChoice,
|
|
44
|
+
noul as tsNoul,
|
|
45
|
+
score as tsScore,
|
|
46
|
+
type EntryType,
|
|
47
|
+
} from "@typesafe-ai/sdk";
|
|
48
|
+
import type { ProviderConfig, ProviderId } from "./config.js";
|
|
49
|
+
import { gatewayUnavailable, modelUnauthorized, timeoutError, type JanusError } from "./errors.js";
|
|
50
|
+
|
|
51
|
+
export const TYPESAFE_SDK_VERSION = "0.6.0";
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Transport contract (provider-independent)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export interface TransportQuestion {
|
|
58
|
+
/** Stable, non-sensitive question identifier (answer key + audit name). */
|
|
59
|
+
name: string;
|
|
60
|
+
/** Question kind, identical across adapters. */
|
|
61
|
+
kind: "boolean" | "choice" | "score";
|
|
62
|
+
/** The question text — ALWAYS sent to the provider, verbatim. */
|
|
63
|
+
question: string;
|
|
64
|
+
/** Optional framing instructions, appended unambiguously after the question. */
|
|
65
|
+
instructions: string;
|
|
66
|
+
/** choice → candidate set. */
|
|
67
|
+
choices?: string[] | undefined;
|
|
68
|
+
/** boolean → optional true/false criteria labels. */
|
|
69
|
+
sdkCriteria?: { true?: string | null; false?: string | null } | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TransportRequest {
|
|
73
|
+
state: string | Record<string, unknown> | unknown[];
|
|
74
|
+
questions: TransportQuestion[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TransportUsage {
|
|
78
|
+
inputTokens: number | undefined;
|
|
79
|
+
outputTokens: number | undefined;
|
|
80
|
+
totalTokens: number | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TransportResult {
|
|
84
|
+
/** Raw provider answers keyed by question NAME. */
|
|
85
|
+
answers: Record<string, unknown>;
|
|
86
|
+
usage: TransportUsage;
|
|
87
|
+
modelId: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A provider adapter. `key` is injected per call (fresh from the Keychain);
|
|
92
|
+
* adapters never persist or log it.
|
|
93
|
+
*/
|
|
94
|
+
export interface ProviderAdapter {
|
|
95
|
+
readonly id: ProviderId;
|
|
96
|
+
evaluate(request: TransportRequest, key: string, signal: AbortSignal): Promise<TransportResult>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fixed public failure classification. `message` is always a fixed string —
|
|
101
|
+
* never provider error text.
|
|
102
|
+
*/
|
|
103
|
+
export interface ClassifiedFailure {
|
|
104
|
+
error: JanusError;
|
|
105
|
+
/** Whether the failover policy may try the next provider once. */
|
|
106
|
+
failoverEligible: boolean;
|
|
107
|
+
/** Usage observed before the failure, when the provider reported any. */
|
|
108
|
+
usage?: TransportUsage | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Question composition (review fix #1)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compose the payload actually sent to the provider. The question text is
|
|
117
|
+
* ALWAYS present and first; framing instructions (when any) are appended in
|
|
118
|
+
* an explicitly delimited block so the model can never confuse framing with
|
|
119
|
+
* the question itself.
|
|
120
|
+
*/
|
|
121
|
+
export function composeQuestionPayload(
|
|
122
|
+
question: string,
|
|
123
|
+
instructions: string,
|
|
124
|
+
): { question: string; framing: string | null } {
|
|
125
|
+
const trimmedQuestion = question;
|
|
126
|
+
if (instructions.length === 0) {
|
|
127
|
+
return { question: trimmedQuestion, framing: null };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
question: trimmedQuestion,
|
|
131
|
+
framing:
|
|
132
|
+
`[Additional framing instructions — context only, not part of the question:]\n` +
|
|
133
|
+
instructions,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Full text sent as the provider question: question first, framing after. */
|
|
138
|
+
export function providerQuestionText(question: TransportQuestion): string {
|
|
139
|
+
const { question: q, framing } = composeQuestionPayload(question.question, question.instructions);
|
|
140
|
+
return framing === null ? q : `${q}\n\n${framing}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// typesafe-direct adapter (PRIMARY) — official @typesafe-ai/sdk
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Minimal structural client surface, injectable for tests (no network). */
|
|
148
|
+
export interface TypeSafeClientLike {
|
|
149
|
+
systemOne(
|
|
150
|
+
request: {
|
|
151
|
+
state: EntryType;
|
|
152
|
+
questions: Record<string, TypesafeQuestion>;
|
|
153
|
+
},
|
|
154
|
+
options?: { signal?: AbortSignal; timeout?: number },
|
|
155
|
+
): Promise<{
|
|
156
|
+
answers: Record<
|
|
157
|
+
string,
|
|
158
|
+
| { type: "noul"; noul: number }
|
|
159
|
+
| { type: "choice"; choice: string; probabilities?: Record<string, number> }
|
|
160
|
+
| { type: "score"; score: number; probabilities?: Record<string, number> }
|
|
161
|
+
>;
|
|
162
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
163
|
+
model: string;
|
|
164
|
+
}>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Score vocabulary sent identically by every adapter (§6: 0..5). */
|
|
168
|
+
export const SCORE_LABELS = ["0", "1", "2", "3", "4", "5"] as const;
|
|
169
|
+
|
|
170
|
+
/** Map a transport question to the TypeSafe question shape. */
|
|
171
|
+
export function toTypesafeQuestion(question: TransportQuestion): ReturnType<typeof buildTypesafeQuestion> {
|
|
172
|
+
return buildTypesafeQuestion(question);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Structural question shape accepted by the TypeSafe client (tests inject mocks of this). */
|
|
176
|
+
export type TypesafeQuestion =
|
|
177
|
+
| { type: "noul"; instructions?: EntryType; criteria?: { true?: EntryType; false?: EntryType } | null }
|
|
178
|
+
| { type: "choice"; instructions?: EntryType; criteria: Record<string, EntryType> }
|
|
179
|
+
| { type: "score"; instructions?: EntryType; criteria: readonly [EntryType, EntryType, ...EntryType[]] };
|
|
180
|
+
|
|
181
|
+
function buildTypesafeQuestion(question: TransportQuestion): TypesafeQuestion {
|
|
182
|
+
const instructions = providerQuestionText(question);
|
|
183
|
+
switch (question.kind) {
|
|
184
|
+
case "boolean": {
|
|
185
|
+
const criteria: { true?: EntryType; false?: EntryType } = {};
|
|
186
|
+
if (question.sdkCriteria?.true !== undefined) criteria.true = question.sdkCriteria.true;
|
|
187
|
+
if (question.sdkCriteria?.false !== undefined) criteria.false = question.sdkCriteria.false;
|
|
188
|
+
return Object.keys(criteria).length > 0
|
|
189
|
+
? tsNoul(instructions, criteria)
|
|
190
|
+
: tsNoul(instructions);
|
|
191
|
+
}
|
|
192
|
+
case "choice": {
|
|
193
|
+
const criteria: Record<string, EntryType> = {};
|
|
194
|
+
for (const option of question.choices ?? []) criteria[option] = null;
|
|
195
|
+
return tsChoice(instructions, criteria);
|
|
196
|
+
}
|
|
197
|
+
case "score":
|
|
198
|
+
return tsScore(instructions, [...SCORE_LABELS] as [EntryType, EntryType, ...EntryType[]]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export class TypesafeDirectAdapter implements ProviderAdapter {
|
|
203
|
+
readonly id = "typesafe-direct" as const;
|
|
204
|
+
|
|
205
|
+
constructor(
|
|
206
|
+
/** Injected for tests; defaults to the real official SDK client. */
|
|
207
|
+
private readonly clientFactory: (apiKey: string) => TypeSafeClientLike = (apiKey) =>
|
|
208
|
+
new TypeSafeClient({
|
|
209
|
+
apiKey,
|
|
210
|
+
// janus owns retries and failover (review fix #12): no SDK retries.
|
|
211
|
+
retry: { maxRetries: 0 },
|
|
212
|
+
// No SDK logging of request bodies or error details.
|
|
213
|
+
logLevel: "off",
|
|
214
|
+
}),
|
|
215
|
+
) {}
|
|
216
|
+
|
|
217
|
+
async evaluate(request: TransportRequest, key: string, signal: AbortSignal): Promise<TransportResult> {
|
|
218
|
+
const client = this.clientFactory(key);
|
|
219
|
+
const questions: Record<string, TypesafeQuestion> = {};
|
|
220
|
+
for (const question of request.questions) {
|
|
221
|
+
questions[question.name] = buildTypesafeQuestion(question);
|
|
222
|
+
}
|
|
223
|
+
let result;
|
|
224
|
+
try {
|
|
225
|
+
result = await client.systemOne(
|
|
226
|
+
{
|
|
227
|
+
state: request.state as EntryType,
|
|
228
|
+
questions: questions as Parameters<TypeSafeClientLike["systemOne"]>[0]["questions"],
|
|
229
|
+
},
|
|
230
|
+
{ signal },
|
|
231
|
+
);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
throw classifyTypesafeError(error);
|
|
234
|
+
}
|
|
235
|
+
const answers: Record<string, unknown> = {};
|
|
236
|
+
for (const [name, answer] of Object.entries(result.answers)) {
|
|
237
|
+
if (answer.type === "noul") {
|
|
238
|
+
answers[name] = { type: "boolean", probability: answer.noul };
|
|
239
|
+
} else if (answer.type === "choice") {
|
|
240
|
+
answers[name] = {
|
|
241
|
+
type: "choice",
|
|
242
|
+
choice: answer.choice,
|
|
243
|
+
...(answer.probabilities !== undefined ? { probabilities: answer.probabilities } : {}),
|
|
244
|
+
};
|
|
245
|
+
} else {
|
|
246
|
+
answers[name] = {
|
|
247
|
+
type: "score",
|
|
248
|
+
score: answer.score,
|
|
249
|
+
...(answer.probabilities !== undefined ? { probabilities: answer.probabilities } : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
answers,
|
|
255
|
+
usage: {
|
|
256
|
+
inputTokens: result.usage.input_tokens,
|
|
257
|
+
outputTokens: result.usage.output_tokens,
|
|
258
|
+
totalTokens: result.usage.input_tokens + result.usage.output_tokens,
|
|
259
|
+
},
|
|
260
|
+
modelId: result.model,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Classify a TypeSafe SDK error into a fixed-public-message failure. */
|
|
266
|
+
export function classifyTypesafeError(error: unknown): ProviderTransportError {
|
|
267
|
+
if (error instanceof ProviderTransportError) return error;
|
|
268
|
+
if (error instanceof APIUserAbortError) {
|
|
269
|
+
return new ProviderTransportError("timeout", timeoutError("evaluation timed out"));
|
|
270
|
+
}
|
|
271
|
+
if (error instanceof APITimeoutError || error instanceof APIConnectionError) {
|
|
272
|
+
return new ProviderTransportError(
|
|
273
|
+
"gateway_unavailable",
|
|
274
|
+
gatewayUnavailable("provider unreachable or timed out"),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (error instanceof APIError) {
|
|
278
|
+
if (error.status === 401 || error.status === 403) {
|
|
279
|
+
return new ProviderTransportError(
|
|
280
|
+
"model_unauthorized",
|
|
281
|
+
modelUnauthorized("provider rejected the credential"),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (error.status === 429) {
|
|
285
|
+
return new ProviderTransportError(
|
|
286
|
+
"gateway_unavailable",
|
|
287
|
+
gatewayUnavailable("provider rate limit reached"),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (error.status === 404) {
|
|
291
|
+
return new ProviderTransportError(
|
|
292
|
+
"gateway_unavailable",
|
|
293
|
+
gatewayUnavailable("model not available on the provider"),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return new ProviderTransportError(
|
|
297
|
+
"gateway_unavailable",
|
|
298
|
+
gatewayUnavailable("provider evaluation failed"),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (error instanceof TypeSafeError) {
|
|
302
|
+
return new ProviderTransportError(
|
|
303
|
+
"gateway_unavailable",
|
|
304
|
+
gatewayUnavailable("provider evaluation failed"),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
308
|
+
return new ProviderTransportError("timeout", timeoutError("evaluation timed out"));
|
|
309
|
+
}
|
|
310
|
+
return new ProviderTransportError(
|
|
311
|
+
"gateway_unavailable",
|
|
312
|
+
gatewayUnavailable("provider evaluation failed"),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Raw-error classification (fixed public messages)
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** Classify a raw (non-janus) error into a fixed-public-message failure. */
|
|
321
|
+
export function classifyGatewayError(error: unknown): ProviderTransportError {
|
|
322
|
+
if (error instanceof ProviderTransportError) return error;
|
|
323
|
+
const name = error instanceof Error ? error.name : "";
|
|
324
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
325
|
+
const text = `${name} ${message}`.toLowerCase();
|
|
326
|
+
|
|
327
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
328
|
+
return new ProviderTransportError("timeout", timeoutError("evaluation timed out"));
|
|
329
|
+
}
|
|
330
|
+
if (/abort/.test(text) && /timeout|timed out|signal is aborted/.test(text)) {
|
|
331
|
+
return new ProviderTransportError("timeout", timeoutError("evaluation timed out"));
|
|
332
|
+
}
|
|
333
|
+
if (/401|unauthorized|invalid api key|authentication|api key/i.test(message)) {
|
|
334
|
+
return new ProviderTransportError(
|
|
335
|
+
"model_unauthorized",
|
|
336
|
+
modelUnauthorized("provider rejected the credential"),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (/403|forbidden/i.test(message)) {
|
|
340
|
+
return new ProviderTransportError(
|
|
341
|
+
"model_unauthorized",
|
|
342
|
+
modelUnauthorized("provider refused access"),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (/404|not found|no such model|model_not_found/i.test(message)) {
|
|
346
|
+
return new ProviderTransportError(
|
|
347
|
+
"gateway_unavailable",
|
|
348
|
+
gatewayUnavailable("model not available on the provider"),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (/429|rate limit|quota/i.test(message)) {
|
|
352
|
+
return new ProviderTransportError(
|
|
353
|
+
"gateway_unavailable",
|
|
354
|
+
gatewayUnavailable("provider rate limit reached"),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (/econnrefused|enotfound|etimedout|econnreset|fetch failed|network|socket|dns/i.test(text)) {
|
|
358
|
+
return new ProviderTransportError(
|
|
359
|
+
"gateway_unavailable",
|
|
360
|
+
gatewayUnavailable("provider unreachable"),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return new ProviderTransportError(
|
|
364
|
+
"gateway_unavailable",
|
|
365
|
+
gatewayUnavailable("provider evaluation failed"),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Failover chain
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Terminal chain failure. Carries the taxonomy error PLUS every failed
|
|
375
|
+
* attempt's accumulated usage (including the terminal attempt) so the
|
|
376
|
+
* Evaluator can charge all real provider consumption exactly once even when
|
|
377
|
+
* the chain ultimately throws (review fix #12).
|
|
378
|
+
*/
|
|
379
|
+
export class ProviderChainError extends Error {
|
|
380
|
+
constructor(
|
|
381
|
+
readonly failure: JanusError,
|
|
382
|
+
readonly failedAttemptUsage: TransportUsage[],
|
|
383
|
+
) {
|
|
384
|
+
super(failure.message);
|
|
385
|
+
this.name = "ProviderChainError";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A transport-level failure carrying its fixed classification. */
|
|
390
|
+
export class ProviderTransportError extends Error {
|
|
391
|
+
readonly failure: ClassifiedFailure;
|
|
392
|
+
|
|
393
|
+
constructor(code: ClassifiedFailure["error"]["code"], error: JanusError, usage?: TransportUsage) {
|
|
394
|
+
super(error.message);
|
|
395
|
+
this.name = "ProviderTransportError";
|
|
396
|
+
this.failure = { error, failoverEligible: FAILOVER_ELIGIBLE.has(code), usage };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Failover is allowed ONLY for transport-level failures (review fix #2). */
|
|
401
|
+
const FAILOVER_ELIGIBLE: ReadonlySet<string> = new Set([
|
|
402
|
+
"gateway_unavailable",
|
|
403
|
+
"timeout",
|
|
404
|
+
"model_unauthorized",
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
export interface ChainEvaluateOutcome {
|
|
408
|
+
result: TransportResult;
|
|
409
|
+
/** Provider id that produced the result. */
|
|
410
|
+
provider: ProviderId;
|
|
411
|
+
/** How many provider attempts were consumed (1 = no failover). */
|
|
412
|
+
attempts: number;
|
|
413
|
+
/** Usage observed from failed attempts, when the provider reported any. */
|
|
414
|
+
failedAttemptUsage: TransportUsage[];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface KeyResolutionLike {
|
|
418
|
+
status: "resolved" | "missing" | "failed";
|
|
419
|
+
key?: string;
|
|
420
|
+
reason?: string;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export type KeyReader = (ref: ProviderConfig["keychain"]) => Promise<KeyResolutionLike>;
|
|
424
|
+
|
|
425
|
+
/** Default key reader: the guarded Keychain capture. */
|
|
426
|
+
export const defaultKeyReader: KeyReader = async (ref) => {
|
|
427
|
+
const { readGatewayKey } = await import("./keychain.js");
|
|
428
|
+
const resolution = await readGatewayKey(ref);
|
|
429
|
+
return resolution.status === "resolved"
|
|
430
|
+
? { status: "resolved", key: resolution.key }
|
|
431
|
+
: { status: resolution.status, reason: resolution.reason };
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
export class ProviderChain {
|
|
435
|
+
private readonly adapters: Map<ProviderId, ProviderAdapter>;
|
|
436
|
+
|
|
437
|
+
constructor(
|
|
438
|
+
private readonly configs: readonly ProviderConfig[],
|
|
439
|
+
adapters: readonly ProviderAdapter[],
|
|
440
|
+
private readonly readKey: KeyReader = defaultKeyReader,
|
|
441
|
+
) {
|
|
442
|
+
this.adapters = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Evaluate with exactly one identical-request failover attempt on eligible
|
|
447
|
+
* transport failures. A missing/failed key on one provider counts as that
|
|
448
|
+
* provider being unavailable and moves to the next (so a missing fallback
|
|
449
|
+
* key never blocks a healthy primary, and vice versa).
|
|
450
|
+
*/
|
|
451
|
+
async evaluate(
|
|
452
|
+
request: TransportRequest,
|
|
453
|
+
signal: AbortSignal,
|
|
454
|
+
providers: readonly ProviderConfig[] = this.configs,
|
|
455
|
+
): Promise<ChainEvaluateOutcome> {
|
|
456
|
+
const failedAttemptUsage: TransportUsage[] = [];
|
|
457
|
+
let attempts = 0;
|
|
458
|
+
let lastFailure: ProviderTransportError | undefined;
|
|
459
|
+
|
|
460
|
+
for (let index = 0; index < providers.length; index += 1) {
|
|
461
|
+
const provider = providers[index]!;
|
|
462
|
+
const adapter = this.adapters.get(provider.id);
|
|
463
|
+
if (adapter === undefined) {
|
|
464
|
+
lastFailure = new ProviderTransportError(
|
|
465
|
+
"gateway_unavailable",
|
|
466
|
+
gatewayUnavailable(`no adapter configured for provider ${provider.id}`),
|
|
467
|
+
);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The failover policy allows exactly ONE failover attempt: the first
|
|
472
|
+
// eligible failure moves to the next provider; a second failure ends
|
|
473
|
+
// the chain even if more providers are configured.
|
|
474
|
+
if (attempts >= 2) break;
|
|
475
|
+
|
|
476
|
+
const keyResolution = await this.readKey(provider.keychain);
|
|
477
|
+
if (keyResolution.status !== "resolved" || keyResolution.key === undefined) {
|
|
478
|
+
attempts += 1;
|
|
479
|
+
lastFailure = new ProviderTransportError(
|
|
480
|
+
"model_unauthorized",
|
|
481
|
+
modelUnauthorized(`provider credential unavailable for ${provider.id}`),
|
|
482
|
+
);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
attempts += 1;
|
|
487
|
+
try {
|
|
488
|
+
const result = await adapter.evaluate(request, keyResolution.key, signal);
|
|
489
|
+
return { result, provider: provider.id, attempts, failedAttemptUsage };
|
|
490
|
+
} catch (error) {
|
|
491
|
+
const failure =
|
|
492
|
+
error instanceof ProviderTransportError
|
|
493
|
+
? error
|
|
494
|
+
: // Raw errors from adapters (including test mocks) go through
|
|
495
|
+
// the same fixed-public classification.
|
|
496
|
+
classifyGatewayError(error);
|
|
497
|
+
lastFailure = failure;
|
|
498
|
+
if (failure.failure.usage !== undefined) {
|
|
499
|
+
failedAttemptUsage.push(failure.failure.usage);
|
|
500
|
+
}
|
|
501
|
+
const next = providers[index + 1];
|
|
502
|
+
if (failure.failure.failoverEligible && next !== undefined) {
|
|
503
|
+
continue; // the single failover attempt
|
|
504
|
+
}
|
|
505
|
+
// Terminal: the taxonomy error travels with ALL accumulated attempt
|
|
506
|
+
// usage so nothing is uncharged (review fix #12).
|
|
507
|
+
throw new ProviderChainError(failure.failure.error, failedAttemptUsage);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
throw new ProviderChainError(
|
|
512
|
+
lastFailure?.failure.error ?? gatewayUnavailable("no provider produced a verdict"),
|
|
513
|
+
failedAttemptUsage,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Readiness: ready when at least one configured provider has a resolvable
|
|
519
|
+
* key. The primary being healthy is sufficient on its own — missing
|
|
520
|
+
* fallback keys never block readiness. Reasons are sanitized (provider
|
|
521
|
+
* ids only; never Keychain stderr or key fragments).
|
|
522
|
+
*/
|
|
523
|
+
async readiness(
|
|
524
|
+
providers: readonly ProviderConfig[] = this.configs,
|
|
525
|
+
): Promise<{ ready: boolean; reason?: string; providers: { id: ProviderId; ready: boolean }[] }> {
|
|
526
|
+
const statuses: { id: ProviderId; ready: boolean }[] = [];
|
|
527
|
+
for (const provider of providers) {
|
|
528
|
+
const resolution = await this.readKey(provider.keychain);
|
|
529
|
+
statuses.push({ id: provider.id, ready: resolution.status === "resolved" });
|
|
530
|
+
}
|
|
531
|
+
const ready = statuses.some((entry) => entry.ready);
|
|
532
|
+
if (ready) return { ready, providers: statuses };
|
|
533
|
+
return {
|
|
534
|
+
ready,
|
|
535
|
+
reason: `no provider credential resolvable (checked: ${statuses.map((s) => s.id).join(", ")})`,
|
|
536
|
+
providers: statuses,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Build the adapter set for a provider chain (production wiring). */
|
|
542
|
+
export function defaultAdapters(): ProviderAdapter[] {
|
|
543
|
+
return [new TypesafeDirectAdapter()];
|
|
544
|
+
}
|