@mupt-ai/dari-gateway 0.3.1 → 0.3.2
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.md +14 -6
- package/dari-gateway.example.yaml +3 -2
- package/dist/http/auth.js +2 -2
- package/dist/http/http2_server.d.ts +1 -1
- package/dist/http/http2_server.js +3 -3
- package/dist/http/serve.d.ts +1 -0
- package/dist/http/serve.js +5 -4
- package/dist/ingress/chat_completions.js +2 -2
- package/dist/ingress/codex.js +3 -1
- package/dist/ingress/codex_models.d.ts +1 -1
- package/dist/ingress/codex_models.js +22 -13
- package/dist/platform/platform.d.ts +13 -3
- package/dist/protocol/input/openai_responses.js +2 -4
- package/dist/protocol/output/anthropic_messages_output.d.ts +2 -2
- package/dist/protocol/output/anthropic_messages_output.js +1 -0
- package/dist/protocol/output/openai_responses_stream.js +19 -1
- package/dist/providers/execution.d.ts +0 -5
- package/dist/providers/execution.js +5 -8
- package/dist/providers/pi_output.d.ts +4 -0
- package/dist/providers/pi_output.js +1 -1
- package/dist/providers/provider_attempt_runner.d.ts +2 -3
- package/dist/providers/provider_attempt_runner.js +14 -24
- package/dist/providers/provider_call.d.ts +3 -3
- package/dist/providers/provider_call.js +1 -0
- package/dist/providers/stream_finalization.d.ts +0 -1
- package/dist/providers/stream_finalization.js +0 -1
- package/dist/runtime/claude_code/auxiliary_routing.js +1 -9
- package/dist/runtime/explicit_model.js +1 -1
- package/dist/runtime/harness_identity/conversation.d.ts +5 -0
- package/dist/runtime/harness_identity/conversation.js +6 -0
- package/dist/runtime/harness_identity/extract.d.ts +1 -0
- package/dist/runtime/harness_identity/extract.js +13 -5
- package/dist/runtime/harness_identity/index.d.ts +2 -2
- package/dist/runtime/harness_identity/index.js +2 -2
- package/dist/runtime/prepare_routed_completion.d.ts +5 -1
- package/dist/runtime/prepare_routed_completion.js +26 -2
- package/dist/runtime/routed_runtime.js +33 -53
- package/dist/runtime/routing_lease.d.ts +4 -6
- package/dist/runtime/routing_lease.js +2 -47
- package/dist/runtime/runtime_reporter.d.ts +0 -2
- package/dist/runtime/runtime_reporter.js +0 -1
- package/dist/runtime/session_pin.d.ts +7 -0
- package/dist/runtime/session_pin.js +51 -0
- package/dist/self_host/config.d.ts +2 -3
- package/dist/self_host/config.js +1 -2
- package/dist/self_host/main.d.ts +2 -1
- package/dist/self_host/main.js +20 -4
- package/dist/self_host/platform.d.ts +4 -2
- package/dist/self_host/platform.js +6 -2
- package/dist/self_host/resolve.d.ts +8 -1
- package/dist/self_host/resolve.js +31 -8
- package/dist/self_host/session_pin_store.d.ts +14 -0
- package/dist/self_host/session_pin_store.js +44 -0
- package/dist/strategies/priority_selector.d.ts +1 -2
- package/dist/strategies/priority_selector.js +1 -2
- package/dist/strategies/route_decision.d.ts +0 -1
- package/dist/strategies/route_decision.js +0 -3
- package/dist/strategies/routing.d.ts +0 -3
- package/dist/strategies/routing.js +1 -14
- package/dist/telemetry/billing_finalization.d.ts +1 -2
- package/dist/telemetry/billing_finalization.js +1 -1
- package/dist/telemetry/prefix_state.d.ts +0 -1
- package/dist/telemetry/prefix_state.js +1 -3
- package/dist/telemetry/report.d.ts +0 -2
- package/dist/telemetry/report.js +5 -41
- package/dist/types.d.ts +5 -3
- package/package.json +2 -2
- package/src/http/auth.ts +2 -2
- package/src/http/http2_server.ts +7 -3
- package/src/http/serve.ts +6 -5
- package/src/ingress/chat_completions.ts +2 -1
- package/src/ingress/codex.ts +4 -1
- package/src/ingress/codex_models.ts +18 -7
- package/src/platform/platform.ts +16 -2
- package/src/protocol/input/openai_responses.ts +2 -9
- package/src/protocol/output/anthropic_messages_output.ts +3 -2
- package/src/protocol/output/openai_responses_stream.ts +17 -1
- package/src/providers/execution.ts +5 -15
- package/src/providers/pi_output.ts +1 -1
- package/src/providers/provider_attempt_runner.ts +10 -21
- package/src/providers/provider_call.ts +3 -1
- package/src/providers/stream_finalization.ts +0 -2
- package/src/providers/subscription_limits.ts +1 -1
- package/src/runtime/claude_code/auxiliary_routing.ts +1 -8
- package/src/runtime/explicit_model.ts +1 -1
- package/src/runtime/harness_identity/conversation.ts +11 -0
- package/src/runtime/harness_identity/extract.ts +14 -5
- package/src/runtime/harness_identity/index.ts +2 -0
- package/src/runtime/prepare_routed_completion.ts +32 -2
- package/src/runtime/routed_runtime.ts +39 -59
- package/src/runtime/routing_lease.ts +2 -56
- package/src/runtime/runtime_reporter.ts +0 -3
- package/src/runtime/session_pin.ts +61 -0
- package/src/self_host/config.ts +3 -6
- package/src/self_host/main.ts +23 -4
- package/src/self_host/platform.ts +8 -2
- package/src/self_host/resolve.ts +37 -7
- package/src/self_host/session_pin_store.ts +58 -0
- package/src/strategies/priority_selector.ts +1 -2
- package/src/strategies/route_decision.ts +0 -5
- package/src/strategies/routing.ts +1 -17
- package/src/telemetry/billing_finalization.ts +4 -3
- package/src/telemetry/prefix_state.ts +1 -6
- package/src/telemetry/report.ts +4 -48
- package/src/types.ts +12 -6
- package/dist/strategies/fallback_decision.d.ts +0 -8
- package/dist/strategies/fallback_decision.js +0 -17
- package/src/strategies/fallback_decision.ts +0 -30
|
@@ -14,11 +14,10 @@ export function chatCompletionsConversationIdentity(
|
|
|
14
14
|
headers: Headers,
|
|
15
15
|
promptCacheKey?: string,
|
|
16
16
|
): HarnessConversationIdentity | undefined {
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
const sessionId = boundedIdentity(
|
|
17
|
+
// Pi summaries use fresh cache/affinity IDs. Its explicit session header
|
|
18
|
+
// groups them with the main turns without changing provider cache affinity.
|
|
19
|
+
// Other callers use x-session-affinity, falling back to prompt_cache_key.
|
|
20
|
+
const sessionId = boundedIdentity(headers.get("x-pi-session-id"), "x-pi-session-id") ?? boundedIdentity(
|
|
22
21
|
headers.get("x-session-affinity"),
|
|
23
22
|
"x-session-affinity",
|
|
24
23
|
) ?? boundedIdentity(promptCacheKey, "prompt_cache_key");
|
|
@@ -34,6 +33,16 @@ export function chatCompletionsConversationIdentity(
|
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
export function piRequestContext(headers: Headers): AgentRequestContext | undefined {
|
|
37
|
+
if (boundedIdentity(headers.get("x-pi-session-id"), "x-pi-session-id") === undefined) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const kind = headers.get("x-pi-request-kind")?.trim();
|
|
41
|
+
return {
|
|
42
|
+
requestClass: kind === "turn" ? "main" : kind === "compaction" ? "compaction" : "unknown",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
// OpenCode names the session behind every request, and a subagent's parent
|
|
38
47
|
// session too. The `dari opencode` plugin adds the request kind and agent,
|
|
39
48
|
// which OpenCode does not send itself. Like Claude Code's headers, these are
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {
|
|
2
|
+
scopedHarnessAgentId,
|
|
2
3
|
scopedHarnessConversationId,
|
|
3
4
|
scopedHarnessSessionId,
|
|
4
5
|
type HarnessConversationIdentity,
|
|
@@ -11,4 +12,5 @@ export {
|
|
|
11
12
|
codexRequestPurpose,
|
|
12
13
|
codexRequestContext,
|
|
13
14
|
openCodeRequestContext,
|
|
15
|
+
piRequestContext,
|
|
14
16
|
} from "./extract.js";
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
import { isBackgroundRequest, type InteractionRequest } from "../interaction/types.js";
|
|
11
11
|
import { executionProviderForModel } from "../models/execution_provider.js";
|
|
12
12
|
import { codexModelConfig, subscriptionServes } from "../models/subscriptions.js";
|
|
13
|
-
import {
|
|
13
|
+
import { explicitModelId } from "../protocol/input/router_model.js";
|
|
14
|
+
import { gatewayPlatform, type RequestTags, type SessionPin } from "../platform/platform.js";
|
|
14
15
|
import { resolveRouter } from "../strategies/routing.js";
|
|
15
16
|
import { RequestReporter, type ReportedCandidatePruning } from "../telemetry/report.js";
|
|
16
17
|
import { RoutingBillingFinalization } from "../telemetry/billing_finalization.js";
|
|
@@ -20,6 +21,7 @@ import { strictProviderAccess } from "./strict_provider_access.js";
|
|
|
20
21
|
import type { RoutedRuntimeReporter } from "./runtime_reporter.js";
|
|
21
22
|
import type { RoutedProtocol } from "./routed_protocol.js";
|
|
22
23
|
import {
|
|
24
|
+
scopedHarnessAgentId,
|
|
23
25
|
scopedHarnessConversationId,
|
|
24
26
|
scopedHarnessSessionId,
|
|
25
27
|
type HarnessConversationIdentity,
|
|
@@ -36,6 +38,9 @@ export type PreparedRoutedCompletion = {
|
|
|
36
38
|
tags: RequestTags;
|
|
37
39
|
harnessConversationIdentity?: HarnessConversationIdentity;
|
|
38
40
|
routing: RoutingContext;
|
|
41
|
+
// Present when this request is served by session pinning: the session's
|
|
42
|
+
// pin key and the pair it pinned, null before its first routed turn.
|
|
43
|
+
sessionPin?: { key: string; pin: SessionPin | null };
|
|
39
44
|
reporter: RoutedRuntimeReporter;
|
|
40
45
|
};
|
|
41
46
|
|
|
@@ -140,6 +145,30 @@ export async function prepareRoutedCompletion(args: {
|
|
|
140
145
|
receivedAt: args.receivedAt,
|
|
141
146
|
});
|
|
142
147
|
const candidateResolution = routing.candidateResolution;
|
|
148
|
+
let sessionPin: PreparedRoutedCompletion["sessionPin"];
|
|
149
|
+
if (
|
|
150
|
+
credentials.session_pinning === true &&
|
|
151
|
+
credentials.strict !== true &&
|
|
152
|
+
!background &&
|
|
153
|
+
explicitModelId(args.interaction.model) === null
|
|
154
|
+
) {
|
|
155
|
+
// Harness identity keys the main agent and each subagent separately and
|
|
156
|
+
// survives compaction. Claude Code names its subagents only with gateway
|
|
157
|
+
// hint headers; without them, and for callers with no harness identity,
|
|
158
|
+
// each conversation is pinned on its own.
|
|
159
|
+
const identity = args.harnessConversationIdentity;
|
|
160
|
+
const unclassifiedClaudeCode = identity?.kind === "claude_code_session"
|
|
161
|
+
&& args.interaction.agentContext?.requestClass === "unknown";
|
|
162
|
+
const key = identity === undefined || unclassifiedClaudeCode
|
|
163
|
+
? routing.conversationId
|
|
164
|
+
: scopedHarnessAgentId({
|
|
165
|
+
identity,
|
|
166
|
+
sourceProtocol: args.interaction.sourceProtocol,
|
|
167
|
+
scope: harnessScope,
|
|
168
|
+
});
|
|
169
|
+
const pin = await gatewayPlatform().readSessionPin({ routerId: canonicalRouterId, pinKey: key });
|
|
170
|
+
sessionPin = { key, pin };
|
|
171
|
+
}
|
|
143
172
|
const reporter = new RequestReporter({
|
|
144
173
|
requestId: args.requestId,
|
|
145
174
|
clientRequestId: args.clientRequestId,
|
|
@@ -184,12 +213,13 @@ export async function prepareRoutedCompletion(args: {
|
|
|
184
213
|
tags,
|
|
185
214
|
harnessConversationIdentity: args.harnessConversationIdentity,
|
|
186
215
|
routing,
|
|
216
|
+
...(sessionPin === undefined ? {} : { sessionPin }),
|
|
187
217
|
reporter,
|
|
188
218
|
};
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
// Ask the platform for each enabled model's credential up front, so provider
|
|
192
|
-
// execution
|
|
222
|
+
// execution stays synchronous.
|
|
193
223
|
async function withProviderCredentials(
|
|
194
224
|
credentials: ResolvedRouter,
|
|
195
225
|
apiKey: string,
|
|
@@ -39,7 +39,6 @@ import { auxiliaryDecision } from "./claude_code/auxiliary_routing.js";
|
|
|
39
39
|
import { explicitModelDecision, strictModelDecision } from "./explicit_model.js";
|
|
40
40
|
import {
|
|
41
41
|
clearPendingLease,
|
|
42
|
-
committedLeaseDecision,
|
|
43
42
|
leaseHoldDecision,
|
|
44
43
|
leasePrefetchDue,
|
|
45
44
|
leasedDecision,
|
|
@@ -47,6 +46,7 @@ import {
|
|
|
47
46
|
prefetchNextLease,
|
|
48
47
|
selectorNegotiatesLeases,
|
|
49
48
|
} from "./routing_lease.js";
|
|
49
|
+
import { pinSession, sessionPinHoldDecision, sessionPinnedSelection } from "./session_pin.js";
|
|
50
50
|
import type { RoutedProtocol } from "./routed_protocol.js";
|
|
51
51
|
import {
|
|
52
52
|
createExperimentRoutedRuntimeReporter,
|
|
@@ -69,7 +69,6 @@ type SpeculativeExecution = {
|
|
|
69
69
|
type RuntimeExecutionPlan = {
|
|
70
70
|
primary: ProviderChoice;
|
|
71
71
|
primaryRetries: number;
|
|
72
|
-
resolveFallback: () => Promise<ProviderChoice | null>;
|
|
73
72
|
reportBarrier?: Promise<unknown>;
|
|
74
73
|
startedCompletion?: StartedCompletion;
|
|
75
74
|
startedStream?: StartedStream;
|
|
@@ -137,7 +136,6 @@ export async function runPreparedRoutedCompletion(args: {
|
|
|
137
136
|
interaction: prepared.interaction,
|
|
138
137
|
primary: plan.primary,
|
|
139
138
|
primaryRetries: plan.primaryRetries,
|
|
140
|
-
resolveFallback: plan.resolveFallback,
|
|
141
139
|
reporter: prepared.reporter,
|
|
142
140
|
protocol: prepared.protocol,
|
|
143
141
|
startedCompletion: plan.startedCompletion,
|
|
@@ -150,7 +148,6 @@ export async function runPreparedRoutedCompletion(args: {
|
|
|
150
148
|
interaction: prepared.interaction,
|
|
151
149
|
primary: plan.primary,
|
|
152
150
|
primaryRetries: plan.primaryRetries,
|
|
153
|
-
resolveFallback: plan.resolveFallback,
|
|
154
151
|
reporter: prepared.reporter,
|
|
155
152
|
startedStream: plan.startedStream,
|
|
156
153
|
initialError: plan.initialStreamError,
|
|
@@ -191,7 +188,6 @@ export async function runPreparedRoutedInteraction(args: {
|
|
|
191
188
|
interaction: args.prepared.interaction,
|
|
192
189
|
primary: plan.primary,
|
|
193
190
|
primaryRetries: plan.primaryRetries,
|
|
194
|
-
resolveFallback: plan.resolveFallback,
|
|
195
191
|
reporter: args.prepared.reporter,
|
|
196
192
|
protocol: args.prepared.protocol,
|
|
197
193
|
startedCompletion: plan.startedCompletion,
|
|
@@ -253,8 +249,7 @@ async function prepareRuntimeExecution(args: {
|
|
|
253
249
|
}): Promise<RuntimeExecutionPlan> {
|
|
254
250
|
const { prepared } = args;
|
|
255
251
|
|
|
256
|
-
// A strict router never selects
|
|
257
|
-
// an error.
|
|
252
|
+
// A strict router never selects or leases: the pinned pair or an error.
|
|
258
253
|
if (prepared.credentials.strict === true) {
|
|
259
254
|
const pinned = strictModelDecision(prepared);
|
|
260
255
|
prepared.reporter.attachServingDecision(pinned.decision);
|
|
@@ -298,14 +293,20 @@ async function prepareRuntimeExecution(args: {
|
|
|
298
293
|
return selectedExecutionPlan(prepared, auxiliary);
|
|
299
294
|
}
|
|
300
295
|
|
|
296
|
+
// A session-pinning router serves the session's pin, or selects once and
|
|
297
|
+
// pins the result. Leases and speculation never apply to a pinned session.
|
|
298
|
+
if (prepared.sessionPin !== undefined && !args.requireSelectorDecision) {
|
|
299
|
+
return selectedExecutionPlan(
|
|
300
|
+
prepared,
|
|
301
|
+
await sessionPinnedDecision(prepared, args.selectorBackend),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
301
305
|
// Persisted policy leases (negotiated {action, turns}) take precedence.
|
|
302
306
|
// Explicit in-process runs are selector experiments: they must observe the
|
|
303
307
|
// selector and must not consume or prefetch live conversations' leases.
|
|
304
308
|
const held = args.requireSelectorDecision ? null : leaseHoldDecision(prepared);
|
|
305
309
|
if (held) {
|
|
306
|
-
// Serving-only attach: nextDecision stays unset so if a fallback serves
|
|
307
|
-
// this turn, execution's attachServingDecision moves both the prefix's
|
|
308
|
-
// next_model and inherited countdown onto the healthy serving pair.
|
|
309
310
|
prepared.reporter.attachServingDecision(held);
|
|
310
311
|
prepared.reporter.attachDecisionDetails({
|
|
311
312
|
decision_source: "lease_hold",
|
|
@@ -324,7 +325,7 @@ async function prepareRuntimeExecution(args: {
|
|
|
324
325
|
if (leasePrefetchDue(prepared)) {
|
|
325
326
|
trackBackgroundTelemetry(prefetchNextLease(prepared, args.selectorBackend));
|
|
326
327
|
}
|
|
327
|
-
return selectedExecutionPlan(prepared,
|
|
328
|
+
return selectedExecutionPlan(prepared, { decision: held });
|
|
328
329
|
}
|
|
329
330
|
const adopted = args.requireSelectorDecision ? null : pendingLeaseDecision(prepared);
|
|
330
331
|
if (adopted) {
|
|
@@ -349,7 +350,7 @@ async function prepareRuntimeExecution(args: {
|
|
|
349
350
|
// wait elapses, the next lease's own prefetch overwrites the stale rows
|
|
350
351
|
// before its expiry (the menu's shortest lease exceeds the lag).
|
|
351
352
|
await clearPendingLease(prepared);
|
|
352
|
-
return selectedExecutionPlan(prepared,
|
|
353
|
+
return selectedExecutionPlan(prepared, { decision: adopted });
|
|
353
354
|
}
|
|
354
355
|
|
|
355
356
|
// The stateless ten-turn hold covers per-turn policies. A lease-negotiating
|
|
@@ -380,7 +381,6 @@ async function prepareRuntimeExecution(args: {
|
|
|
380
381
|
const primary: ProviderChoice = {
|
|
381
382
|
decision: speculative.decision,
|
|
382
383
|
execution: speculative.execution,
|
|
383
|
-
role: "primary",
|
|
384
384
|
};
|
|
385
385
|
const selectionPromise = selectDecision(
|
|
386
386
|
prepared,
|
|
@@ -390,23 +390,13 @@ async function prepareRuntimeExecution(args: {
|
|
|
390
390
|
);
|
|
391
391
|
// The execution layer awaits selection as its report barrier only after the
|
|
392
392
|
// provider settles, which can be long after a rejection. Mark the promise
|
|
393
|
-
// handled now;
|
|
393
|
+
// handled now; the report barrier still observes the original rejection.
|
|
394
394
|
selectionPromise.catch(() => undefined);
|
|
395
|
-
let fallbackPromise: Promise<ProviderChoice | null> | null = null;
|
|
396
|
-
const resolveFallback = (): Promise<ProviderChoice | null> => {
|
|
397
|
-
fallbackPromise ??= selectionPromise.then((selected) => {
|
|
398
|
-
const decision = fallbackForPrimary(prepared, primary.decision, selected);
|
|
399
|
-
if (decision) prepared.reporter.attachFallbackDecision(decision);
|
|
400
|
-
return decision ? providerChoice(prepared, decision, "fallback") : null;
|
|
401
|
-
});
|
|
402
|
-
return fallbackPromise;
|
|
403
|
-
};
|
|
404
395
|
return {
|
|
405
396
|
primary,
|
|
406
397
|
primaryRetries: configuredPrimaryRetries(
|
|
407
398
|
prepared.credentials.primary_retries,
|
|
408
399
|
),
|
|
409
|
-
resolveFallback,
|
|
410
400
|
reportBarrier: selectionPromise,
|
|
411
401
|
startedCompletion:
|
|
412
402
|
speculative.transport === "completion" ? speculative.started : undefined,
|
|
@@ -424,20 +414,10 @@ function selectedExecutionPlan(
|
|
|
424
414
|
selected: RoutedDecision,
|
|
425
415
|
): RuntimeExecutionPlan {
|
|
426
416
|
return {
|
|
427
|
-
primary: providerChoice(prepared, selected.decision
|
|
417
|
+
primary: providerChoice(prepared, selected.decision),
|
|
428
418
|
primaryRetries: configuredPrimaryRetries(
|
|
429
419
|
prepared.credentials.primary_retries,
|
|
430
420
|
),
|
|
431
|
-
resolveFallback: async () => {
|
|
432
|
-
if (!selected.fallbackDecision) return null;
|
|
433
|
-
const fallbackDecision = selected.decision.leaseTurnsRemaining === undefined
|
|
434
|
-
? selected.fallbackDecision
|
|
435
|
-
: {
|
|
436
|
-
...selected.fallbackDecision,
|
|
437
|
-
leaseTurnsRemaining: selected.decision.leaseTurnsRemaining,
|
|
438
|
-
};
|
|
439
|
-
return providerChoice(prepared, fallbackDecision, "fallback");
|
|
440
|
-
},
|
|
441
421
|
};
|
|
442
422
|
}
|
|
443
423
|
|
|
@@ -503,6 +483,28 @@ async function startSpeculativeExecution(
|
|
|
503
483
|
}
|
|
504
484
|
}
|
|
505
485
|
|
|
486
|
+
async function sessionPinnedDecision(
|
|
487
|
+
prepared: PreparedRoutedCompletion,
|
|
488
|
+
selectorBackend: SelectorBackend,
|
|
489
|
+
): Promise<RoutedDecision> {
|
|
490
|
+
const held = sessionPinHoldDecision(prepared);
|
|
491
|
+
if (held) {
|
|
492
|
+
prepared.reporter.attachDecision(held);
|
|
493
|
+
prepared.reporter.attachDecisionDetails({
|
|
494
|
+
decision_source: "session_pin",
|
|
495
|
+
session_pin: { model: held.selectedModel, reasoning_effort: held.reasoningEffort },
|
|
496
|
+
});
|
|
497
|
+
return { decision: held };
|
|
498
|
+
}
|
|
499
|
+
const selected = await selectDecision(prepared, null, selectorBackend);
|
|
500
|
+
prepared.reporter.attachDecisionDetails({
|
|
501
|
+
decision_source: "selector",
|
|
502
|
+
session_pin_established: true,
|
|
503
|
+
});
|
|
504
|
+
await pinSession(prepared, selected.decision);
|
|
505
|
+
return selected;
|
|
506
|
+
}
|
|
507
|
+
|
|
506
508
|
async function selectDecision(
|
|
507
509
|
prepared: PreparedRoutedCompletion,
|
|
508
510
|
servingDecision: RoutingDecision | null,
|
|
@@ -511,7 +513,7 @@ async function selectDecision(
|
|
|
511
513
|
): Promise<RoutedDecision> {
|
|
512
514
|
const { credentials, reporter, routing } = prepared;
|
|
513
515
|
try {
|
|
514
|
-
const
|
|
516
|
+
const selected = await routeDecision({
|
|
515
517
|
selectorMessages: routing.selectorMessages,
|
|
516
518
|
selectorImages: routing.selectorImages,
|
|
517
519
|
resolution: routing.candidateResolution,
|
|
@@ -521,6 +523,7 @@ async function selectDecision(
|
|
|
521
523
|
selectorBackend,
|
|
522
524
|
leaseHistory: routing.leaseHistory,
|
|
523
525
|
});
|
|
526
|
+
const routed = prepared.sessionPin === undefined ? selected : sessionPinnedSelection(selected);
|
|
524
527
|
if (routed.details) reporter.attachDecisionDetails(routed.details);
|
|
525
528
|
if (servingDecision) {
|
|
526
529
|
if (!routed.run) {
|
|
@@ -544,7 +547,6 @@ async function selectDecision(
|
|
|
544
547
|
reporter.attachSelectorRun(routed.run);
|
|
545
548
|
} else {
|
|
546
549
|
reporter.attachDecision(routed.decision);
|
|
547
|
-
reporter.attachFallbackDecision(routed.fallbackDecision);
|
|
548
550
|
}
|
|
549
551
|
return routed;
|
|
550
552
|
} catch (error) {
|
|
@@ -592,35 +594,13 @@ function configuredPrimaryRetries(value: number | undefined): number {
|
|
|
592
594
|
return Number.isInteger(retries) && retries >= 0 && retries <= 5 ? retries : 1;
|
|
593
595
|
}
|
|
594
596
|
|
|
595
|
-
function fallbackForPrimary(
|
|
596
|
-
prepared: PreparedRoutedCompletion,
|
|
597
|
-
primary: RoutingDecision,
|
|
598
|
-
selected: RoutedDecision,
|
|
599
|
-
): RoutingDecision | undefined {
|
|
600
|
-
if (prepared.credentials.model_fallback_enabled === false) return undefined;
|
|
601
|
-
const fallback = [selected.fallbackDecision, selected.decision].find(
|
|
602
|
-
(candidate): candidate is RoutingDecision =>
|
|
603
|
-
candidate !== undefined &&
|
|
604
|
-
candidate.selectedModel !== primary.selectedModel,
|
|
605
|
-
);
|
|
606
|
-
if (!fallback || selected.decision.leaseTurnsRemaining === undefined) {
|
|
607
|
-
return fallback;
|
|
608
|
-
}
|
|
609
|
-
return {
|
|
610
|
-
...fallback,
|
|
611
|
-
leaseTurnsRemaining: selected.decision.leaseTurnsRemaining,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
597
|
function providerChoice(
|
|
616
598
|
prepared: PreparedRoutedCompletion,
|
|
617
599
|
decision: RoutingDecision,
|
|
618
|
-
role: ProviderChoice["role"],
|
|
619
600
|
): ProviderChoice {
|
|
620
601
|
return {
|
|
621
602
|
decision,
|
|
622
603
|
execution: providerExecution(prepared, decision),
|
|
623
|
-
role,
|
|
624
604
|
};
|
|
625
605
|
}
|
|
626
606
|
|
|
@@ -6,8 +6,6 @@ import {
|
|
|
6
6
|
type RoutingDecision,
|
|
7
7
|
} from "@mupt-ai/dari-router/policy-engine";
|
|
8
8
|
|
|
9
|
-
import { deriveFallbackDecision } from "../strategies/fallback_decision.js";
|
|
10
|
-
import { executionProviderForModel } from "../models/execution_provider.js";
|
|
11
9
|
import type { SelectorBackend } from "../strategies/routing.js";
|
|
12
10
|
import {
|
|
13
11
|
routeDecision,
|
|
@@ -71,17 +69,7 @@ export function leasedDecision(
|
|
|
71
69
|
// incumbent cannot hide an available user-owned key behind the lease.
|
|
72
70
|
if (prepared.billable && prepared.credentials.credit_exhausted) return null;
|
|
73
71
|
|
|
74
|
-
const fallbackDecision = leaseFallbackDecision({
|
|
75
|
-
candidates: prepared.routing.candidateResolution.candidates,
|
|
76
|
-
decision: lease.decision,
|
|
77
|
-
credentials: prepared.credentials,
|
|
78
|
-
});
|
|
79
|
-
// Use attachServingDecision rather than attachDecision so next_model
|
|
80
|
-
// follows the model that actually serves. When fallback handles a primary
|
|
81
|
-
// failure, it inherits this countdown and subsequent held turns continue
|
|
82
|
-
// on the healthy serving pair instead of retrying the failed primary.
|
|
83
72
|
prepared.reporter.attachServingDecision(lease.decision);
|
|
84
|
-
prepared.reporter.attachFallbackDecision(fallbackDecision);
|
|
85
73
|
prepared.reporter.attachDecisionDetails({
|
|
86
74
|
decision_source: "lease_hold",
|
|
87
75
|
routing_lease: {
|
|
@@ -90,49 +78,7 @@ export function leasedDecision(
|
|
|
90
78
|
turn_in_lease: lease.turnInLease,
|
|
91
79
|
},
|
|
92
80
|
});
|
|
93
|
-
return { decision: lease.decision
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Wraps a lease-held (or adopted) decision with a runtime-derived fallback so
|
|
97
|
-
// a mid-lease provider failure degrades like any other turn instead of
|
|
98
|
-
// erroring the request. The fallback inherits the remaining commitment so the
|
|
99
|
-
// completed prefix follows the healthy serving pair for the rest of the lease.
|
|
100
|
-
export function committedLeaseDecision(
|
|
101
|
-
prepared: PreparedRoutedCompletion,
|
|
102
|
-
decision: RoutingDecision,
|
|
103
|
-
): RoutedDecision {
|
|
104
|
-
const fallbackDecision = leaseFallbackDecision({
|
|
105
|
-
candidates: prepared.routing.candidateResolution.candidates,
|
|
106
|
-
decision,
|
|
107
|
-
credentials: prepared.credentials,
|
|
108
|
-
});
|
|
109
|
-
if (fallbackDecision) prepared.reporter.attachFallbackDecision(fallbackDecision);
|
|
110
|
-
return fallbackDecision ? { decision, fallbackDecision } : { decision };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function leaseFallbackDecision(args: {
|
|
114
|
-
candidates: PreparedRoutedCompletion["routing"]["candidateResolution"]["candidates"];
|
|
115
|
-
decision: RoutingDecision;
|
|
116
|
-
credentials: PreparedRoutedCompletion["credentials"];
|
|
117
|
-
}): RoutingDecision | undefined {
|
|
118
|
-
const fallback = deriveFallbackDecision({
|
|
119
|
-
candidates: args.candidates,
|
|
120
|
-
selected: {
|
|
121
|
-
model: args.decision.selectedModel,
|
|
122
|
-
reasoningEffort: args.decision.reasoningEffort,
|
|
123
|
-
},
|
|
124
|
-
enabled: args.credentials.model_fallback_enabled !== false,
|
|
125
|
-
requiresDifferentProvider:
|
|
126
|
-
args.credentials.fallback_requires_different_provider !== false,
|
|
127
|
-
providerForModel: (model) => executionProviderForModel(args.credentials, model),
|
|
128
|
-
});
|
|
129
|
-
if (!fallback || args.decision.leaseTurnsRemaining === undefined) {
|
|
130
|
-
return fallback;
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
...fallback,
|
|
134
|
-
leaseTurnsRemaining: args.decision.leaseTurnsRemaining,
|
|
135
|
-
};
|
|
81
|
+
return { decision: lease.decision };
|
|
136
82
|
}
|
|
137
83
|
|
|
138
84
|
export async function selectorNegotiatesLeases(
|
|
@@ -220,7 +166,7 @@ export async function clearPendingLease(
|
|
|
220
166
|
);
|
|
221
167
|
}
|
|
222
168
|
|
|
223
|
-
function canServeCommittedPair(
|
|
169
|
+
export function canServeCommittedPair(
|
|
224
170
|
prepared: PreparedRoutedCompletion,
|
|
225
171
|
pair: { model: string; reasoningEffort: string },
|
|
226
172
|
): boolean {
|
|
@@ -46,7 +46,6 @@ export type ReportableProviderOutcome =
|
|
|
46
46
|
export type ProviderAttemptReport = {
|
|
47
47
|
model: string;
|
|
48
48
|
reasoning_effort: string;
|
|
49
|
-
role: "primary" | "fallback";
|
|
50
49
|
attempt: number;
|
|
51
50
|
outcome: "completed" | "error" | "terminal_error" | "canceled";
|
|
52
51
|
};
|
|
@@ -65,7 +64,6 @@ export type RoutedRuntimeReporter = {
|
|
|
65
64
|
attachSelectorRun(run: SelectorRun): void;
|
|
66
65
|
attachSpeculativeSelectorRun(run: SelectorRun, servingDecision: RoutingDecision): void;
|
|
67
66
|
attachDecision(decision: RoutingDecision): void;
|
|
68
|
-
attachFallbackDecision(decision: RoutingDecision | undefined): void;
|
|
69
67
|
attachServingDecision(decision: RoutingDecision): void;
|
|
70
68
|
recordProviderAttempt(attempt: ProviderAttemptRecord): void;
|
|
71
69
|
modelCost(attempt?: ProviderAttemptRecord): number | null;
|
|
@@ -94,7 +92,6 @@ export function createExperimentRoutedRuntimeReporter(): ExperimentRoutedRuntime
|
|
|
94
92
|
attachSelectorRun() {},
|
|
95
93
|
attachSpeculativeSelectorRun() {},
|
|
96
94
|
attachDecision() {},
|
|
97
|
-
attachFallbackDecision() {},
|
|
98
95
|
attachServingDecision() {},
|
|
99
96
|
recordProviderAttempt() {},
|
|
100
97
|
modelCost() { return null; },
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { RoutingDecision } from "@mupt-ai/dari-router/policy-engine";
|
|
2
|
+
|
|
3
|
+
import { gatewayPlatform } from "../platform/platform.js";
|
|
4
|
+
import type { RoutedDecision } from "../strategies/route_decision.js";
|
|
5
|
+
import type { ServedDecision } from "../types.js";
|
|
6
|
+
import type { PreparedRoutedCompletion } from "./prepare_routed_completion.js";
|
|
7
|
+
import { canServeCommittedPair } from "./routing_lease.js";
|
|
8
|
+
|
|
9
|
+
// The session's pinned pair, while this request may still serve it. A pin
|
|
10
|
+
// that can no longer serve (model disabled, credits exhausted, image input on
|
|
11
|
+
// a text-only model) falls through to selection, which moves the pin.
|
|
12
|
+
export function sessionPinHoldDecision(
|
|
13
|
+
prepared: PreparedRoutedCompletion,
|
|
14
|
+
): ServedDecision | null {
|
|
15
|
+
const pin = prepared.sessionPin?.pin;
|
|
16
|
+
if (pin === null || pin === undefined) return null;
|
|
17
|
+
if (!canServeCommittedPair(prepared, pin)) return null;
|
|
18
|
+
return {
|
|
19
|
+
selectedModel: pin.model,
|
|
20
|
+
reasoningEffort: pin.reasoningEffort,
|
|
21
|
+
reason: `Session pin: ${pin.model}/${pin.reasoningEffort} serves this whole session.`,
|
|
22
|
+
sessionPinned: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A pinned session never holds leases: the pin outlives any countdown.
|
|
27
|
+
export function sessionPinnedSelection(routed: RoutedDecision): RoutedDecision {
|
|
28
|
+
const pinned = (decision: RoutingDecision): ServedDecision => ({ ...unleased(decision), sessionPinned: true });
|
|
29
|
+
return {
|
|
30
|
+
...routed,
|
|
31
|
+
decision: pinned(routed.decision),
|
|
32
|
+
...(routed.run === undefined ? {} : { run: { ...routed.run, decision: pinned(routed.run.decision) } }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function unleased(decision: RoutingDecision): RoutingDecision {
|
|
37
|
+
const { leaseTurnsRemaining: _, ...rest } = decision;
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A failed write only costs the pin: the next turn selects again.
|
|
42
|
+
export async function pinSession(
|
|
43
|
+
prepared: PreparedRoutedCompletion,
|
|
44
|
+
decision: RoutingDecision,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (prepared.sessionPin === undefined) return;
|
|
47
|
+
const pin = { model: decision.selectedModel, reasoningEffort: decision.reasoningEffort };
|
|
48
|
+
try {
|
|
49
|
+
await gatewayPlatform().writeSessionPin({
|
|
50
|
+
routerId: prepared.routerId,
|
|
51
|
+
pinKey: prepared.sessionPin.key,
|
|
52
|
+
...pin,
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
gatewayPlatform().captureOperationalError(error, {
|
|
56
|
+
code: "session_pin_write_failed",
|
|
57
|
+
requestId: prepared.requestId,
|
|
58
|
+
routerId: prepared.routerId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/self_host/config.ts
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
* - anthropic/claude-sonnet-5-5
|
|
15
15
|
* - id: openai/gpt-5.5
|
|
16
16
|
* thinking: [medium, high]
|
|
17
|
-
* fallback: true # retry on the next model when a provider fails
|
|
18
17
|
* subscriptions: true # serve with connected Claude Code / ChatGPT plans
|
|
18
|
+
* sessionPinning: false # route each session and subagent once, then keep its model
|
|
19
19
|
* providers: # optional: OpenAI-compatible or self-run endpoints
|
|
20
20
|
* local:
|
|
21
21
|
* baseUrl: http://localhost:8000/v1
|
|
@@ -44,10 +44,9 @@ export type ProviderEndpointConfig = {
|
|
|
44
44
|
|
|
45
45
|
export type RouterConfig = {
|
|
46
46
|
models: RouterModelConfig[];
|
|
47
|
-
fallback: boolean;
|
|
48
|
-
fallbackRequiresDifferentProvider: boolean;
|
|
49
47
|
retries: number;
|
|
50
48
|
subscriptions: boolean;
|
|
49
|
+
sessionPinning: boolean;
|
|
51
50
|
zdr: boolean;
|
|
52
51
|
};
|
|
53
52
|
|
|
@@ -129,11 +128,9 @@ function parseRouter(id: string, raw: unknown): RouterConfig {
|
|
|
129
128
|
}
|
|
130
129
|
return {
|
|
131
130
|
models,
|
|
132
|
-
fallback: optionalBoolean(raw.fallback, `routers.${id}.fallback`) ?? true,
|
|
133
|
-
fallbackRequiresDifferentProvider:
|
|
134
|
-
optionalBoolean(raw.fallbackRequiresDifferentProvider, `routers.${id}.fallbackRequiresDifferentProvider`) ?? false,
|
|
135
131
|
retries: optionalRetries(raw.retries, `routers.${id}.retries`) ?? 1,
|
|
136
132
|
subscriptions: optionalBoolean(raw.subscriptions, `routers.${id}.subscriptions`) ?? true,
|
|
133
|
+
sessionPinning: optionalBoolean(raw.sessionPinning, `routers.${id}.sessionPinning`) ?? false,
|
|
137
134
|
zdr: optionalBoolean(raw.zdr, `routers.${id}.zdr`) ?? false,
|
|
138
135
|
};
|
|
139
136
|
}
|
package/src/self_host/main.ts
CHANGED
|
@@ -12,8 +12,9 @@ import { createPrioritySelectorBackend } from "../strategies/priority_selector.j
|
|
|
12
12
|
import type { SelectorBackend } from "../strategies/routing.js";
|
|
13
13
|
import { loadGatewayConfig } from "./config.js";
|
|
14
14
|
import { createSelfHostedPlatform } from "./platform.js";
|
|
15
|
-
import { gatewayKeyIndex } from "./resolve.js";
|
|
15
|
+
import { gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
|
|
16
16
|
import { PrefixStore } from "./prefix_store.js";
|
|
17
|
+
import { SessionPinStore } from "./session_pin_store.js";
|
|
17
18
|
import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
|
|
18
19
|
|
|
19
20
|
const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
|
|
@@ -27,7 +28,8 @@ const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
|
|
|
27
28
|
* state (default `dari-gateway.db`). It holds subscription tokens.
|
|
28
29
|
* - `DARI_AUTO_ROUTING_API_KEY`: choose among a router's models with Dari
|
|
29
30
|
* Auto Routing; without it, `dari/routing` serves the first listed model.
|
|
30
|
-
* - `PORT` (default 8080),
|
|
31
|
+
* - `PORT` (default 8080), `DARI_GATEWAY_HOST` (default 127.0.0.1; the container
|
|
32
|
+
* image sets 0.0.0.0), and each provider's standard API key variable.
|
|
31
33
|
*/
|
|
32
34
|
export async function startSelfHostedGateway(env: Record<string, string> = process.env as Record<string, string>) {
|
|
33
35
|
const gatewayKeys = (env.DARI_GATEWAY_KEYS ?? "")
|
|
@@ -45,21 +47,35 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
|
|
|
45
47
|
db.exec("PRAGMA journal_mode = WAL");
|
|
46
48
|
const subscriptions = new SubscriptionStore(db);
|
|
47
49
|
const prefixes = new PrefixStore(db);
|
|
50
|
+
const sessionPins = new SessionPinStore(db);
|
|
48
51
|
// Pi's provider auth reads each provider's standard key variable, such as
|
|
49
52
|
// OPENAI_API_KEY, from this environment only.
|
|
50
53
|
const piModels = builtinModels({
|
|
51
54
|
authContext: { env: async (name) => env[name], fileExists: async () => false },
|
|
52
55
|
});
|
|
53
56
|
|
|
57
|
+
const providerApiKey = async (provider: string) => (await piModels.getAuth(provider))?.auth.apiKey;
|
|
54
58
|
const platform = createSelfHostedPlatform({
|
|
55
59
|
config,
|
|
56
60
|
gatewayKeys,
|
|
57
61
|
subscriptions,
|
|
58
62
|
prefixes,
|
|
59
|
-
|
|
63
|
+
sessionPins,
|
|
64
|
+
providerApiKey,
|
|
60
65
|
env,
|
|
61
66
|
fetch,
|
|
62
67
|
});
|
|
68
|
+
// A model without a credential is dropped at request time, so a missing key
|
|
69
|
+
// would otherwise surface only as a 424 on the first request.
|
|
70
|
+
for (const [routerId, router] of Object.entries(config.routers)) {
|
|
71
|
+
const missing = await providersWithoutCredentials({ config, subscriptions, providerApiKey, env }, router);
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
console.warn(
|
|
74
|
+
"dari-gateway router has providers without credentials; their models are not served",
|
|
75
|
+
JSON.stringify({ router: routerId, providers: missing }),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
|
|
64
80
|
const selectorBackend: SelectorBackend = autoRoutingKey
|
|
65
81
|
? createAutoRoutingSelectorBackend({
|
|
@@ -86,10 +102,13 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
|
|
|
86
102
|
};
|
|
87
103
|
|
|
88
104
|
const port = Number(env.PORT ?? "8080");
|
|
89
|
-
|
|
105
|
+
// Loopback unless DARI_GATEWAY_HOST says otherwise (see the doc comment).
|
|
106
|
+
const hostname = (env.DARI_GATEWAY_HOST ?? "").trim() || "127.0.0.1";
|
|
107
|
+
const server = startServer(port, handler, { hostname });
|
|
90
108
|
console.info(
|
|
91
109
|
"dari-gateway listening",
|
|
92
110
|
JSON.stringify({
|
|
111
|
+
hostname,
|
|
93
112
|
port,
|
|
94
113
|
routers: Object.keys(config.routers),
|
|
95
114
|
auto_routing: autoRoutingKey ? "dari" : "priority",
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { errorCode } from "../http/errors.js";
|
|
2
2
|
import type { GatewayPlatform } from "../platform/platform.js";
|
|
3
3
|
import type { PrefixStore } from "./prefix_store.js";
|
|
4
|
+
import type { SessionPinStore } from "./session_pin_store.js";
|
|
4
5
|
import { resolveSelfHostedRouter, type SelfHostedResolverDeps } from "./resolve.js";
|
|
5
6
|
import type { SubscriptionStore } from "./subscriptions/index.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* The self-hosted platform: routers from the config file, subscriptions
|
|
9
|
-
* prefix-cache state in the gateway's SQLite database, and one content-free
|
|
9
|
+
* The self-hosted platform: routers from the config file, subscriptions,
|
|
10
|
+
* prefix-cache state, and session pins in the gateway's SQLite database, and one content-free
|
|
10
11
|
* log line per request.
|
|
11
12
|
*/
|
|
12
13
|
export function createSelfHostedPlatform(deps: Omit<SelfHostedResolverDeps, "prefixHits"> & {
|
|
13
14
|
prefixes: PrefixStore;
|
|
15
|
+
sessionPins: SessionPinStore;
|
|
14
16
|
subscriptions: SubscriptionStore;
|
|
15
17
|
}): GatewayPlatform {
|
|
16
18
|
const resolver: SelfHostedResolverDeps = {
|
|
@@ -25,6 +27,10 @@ export function createSelfHostedPlatform(deps: Omit<SelfHostedResolverDeps, "pre
|
|
|
25
27
|
writePendingLease: async (write) => {
|
|
26
28
|
deps.prefixes.writePendingLease(write, deps.config.routers[write.router_id]?.zdr ?? false);
|
|
27
29
|
},
|
|
30
|
+
readSessionPin: async (key) => deps.sessionPins.read(key),
|
|
31
|
+
writeSessionPin: async (write) => {
|
|
32
|
+
deps.sessionPins.write(write);
|
|
33
|
+
},
|
|
28
34
|
markSubscriptionUnavailable: async (hold) =>
|
|
29
35
|
hold.credentialId !== undefined &&
|
|
30
36
|
deps.subscriptions.markUnavailable(hold.credentialId, hold.limit.resetsAt, hold.limit.reason),
|