@ixo/oracle-runtime-workers 0.13.0 → 0.16.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/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -6,22 +6,83 @@ import {
|
|
|
6
6
|
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
7
7
|
import type { Logger } from '../../plugin-api/types';
|
|
8
8
|
import { NOOP_LOGGER } from '../utils';
|
|
9
|
+
import { isCapabilityGateRefusal } from './capability-gate';
|
|
10
|
+
import { turnCarryOf, turnStart } from './turn-boundary';
|
|
11
|
+
|
|
12
|
+
export { turnStart };
|
|
13
|
+
|
|
14
|
+
/** Identical successful calls a turn may make, per effect. */
|
|
15
|
+
export interface RepetitionCaps {
|
|
16
|
+
reads: number;
|
|
17
|
+
writes: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_REPETITION_CAPS: RepetitionCaps = { reads: 5, writes: 1 };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `TURN_MAX_IDENTICAL_READS` / `TURN_MAX_IDENTICAL_WRITES`; anything
|
|
24
|
+
* that is not a positive number keeps its default (as `turnLimitsFromEnv`).
|
|
25
|
+
*/
|
|
26
|
+
export function repetitionCapsFromEnv(env: {
|
|
27
|
+
TURN_MAX_IDENTICAL_READS?: unknown;
|
|
28
|
+
TURN_MAX_IDENTICAL_WRITES?: unknown;
|
|
29
|
+
}): RepetitionCaps {
|
|
30
|
+
const positive = (raw: unknown, fallback: number): number => {
|
|
31
|
+
if (typeof raw !== 'number' && typeof raw !== 'string') return fallback;
|
|
32
|
+
if (raw === '') return fallback;
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
reads: positive(
|
|
38
|
+
env.TURN_MAX_IDENTICAL_READS,
|
|
39
|
+
DEFAULT_REPETITION_CAPS.reads,
|
|
40
|
+
),
|
|
41
|
+
writes: positive(
|
|
42
|
+
env.TURN_MAX_IDENTICAL_WRITES,
|
|
43
|
+
DEFAULT_REPETITION_CAPS.writes,
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
9
47
|
|
|
10
48
|
export interface ToolRepetitionGuardMiddlewareOptions {
|
|
11
49
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
50
|
+
* At most this many messages back to scan for a prior identical failed
|
|
51
|
+
* call. The scan never reaches past the start of the current turn;
|
|
52
|
+
* unset, it covers the whole turn.
|
|
14
53
|
*/
|
|
15
54
|
lookback?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Effect of a tool by name (the main agent passes its read/write map;
|
|
57
|
+
* a sub-agent dispatch is a write). Unset, every tool is capped as a read.
|
|
58
|
+
*/
|
|
59
|
+
effectOf?: (toolName: string) => 'read' | 'write';
|
|
60
|
+
/** Identical successful calls of a read allowed per turn (default 5, `TURN_MAX_IDENTICAL_READS`). */
|
|
61
|
+
maxIdenticalReads?: number;
|
|
62
|
+
/** Identical successful calls of a write allowed per turn (default 1, `TURN_MAX_IDENTICAL_WRITES`). */
|
|
63
|
+
maxIdenticalWrites?: number;
|
|
16
64
|
/** Optional logger; defaults to a no-op. */
|
|
17
65
|
logger?: Logger;
|
|
18
66
|
}
|
|
19
67
|
|
|
20
68
|
/**
|
|
21
69
|
* Detects when the agent issues the same `(toolName, args)` pair after that
|
|
22
|
-
* exact call already failed
|
|
23
|
-
*
|
|
24
|
-
*
|
|
70
|
+
* exact call already failed in the current turn, and short-circuits before
|
|
71
|
+
* the tool is invoked again. The short-circuit message quotes the earlier
|
|
72
|
+
* error and tells the model to change tools/args instead.
|
|
73
|
+
*
|
|
74
|
+
* A turn starts at the latest human message (the user's, or a sub-agent's
|
|
75
|
+
* task) — not at a human message a tool injects mid-turn (see `turnStart`).
|
|
76
|
+
* A failure from an earlier turn never blocks: the user may have fixed its
|
|
77
|
+
* cause and asked again ("I granted access, try again").
|
|
78
|
+
*
|
|
79
|
+
* It also caps identical calls that SUCCEEDED in the turn: a write may run
|
|
80
|
+
* once per turn with the same arguments (again would repeat its effect —
|
|
81
|
+
* the user can ask again in a new message), a read up to
|
|
82
|
+
* `maxIdenticalReads` times (room for polling, not for a loop). An
|
|
83
|
+
* identical call earlier in the same model response counts too, since the
|
|
84
|
+
* calls of one response run side by side and neither sees the other's
|
|
85
|
+
* result.
|
|
25
86
|
*
|
|
26
87
|
* `toolRetryMiddleware` retries inside one call; this guard prevents the
|
|
27
88
|
* model from making the *next* identical call.
|
|
@@ -30,7 +91,10 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
30
91
|
options: ToolRepetitionGuardMiddlewareOptions = {},
|
|
31
92
|
): AgentMiddleware => {
|
|
32
93
|
const logger = options.logger ?? NOOP_LOGGER;
|
|
33
|
-
const lookback = options.lookback ??
|
|
94
|
+
const lookback = options.lookback ?? Number.POSITIVE_INFINITY;
|
|
95
|
+
const maxReads = options.maxIdenticalReads ?? DEFAULT_REPETITION_CAPS.reads;
|
|
96
|
+
const maxWrites =
|
|
97
|
+
options.maxIdenticalWrites ?? DEFAULT_REPETITION_CAPS.writes;
|
|
34
98
|
|
|
35
99
|
return createMiddleware({
|
|
36
100
|
name: 'ToolRepetitionGuardMiddleware',
|
|
@@ -41,19 +105,22 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
41
105
|
|
|
42
106
|
const argsKey = canonicalArgsKey(toolCall.args);
|
|
43
107
|
const messages = state.messages ?? [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
108
|
+
const opening = turnStart(messages);
|
|
109
|
+
const start = Math.max(opening, messages.length - lookback, 0);
|
|
110
|
+
const argsById = toolCallArgsById(messages, start);
|
|
111
|
+
// The turn's calls a mid-turn summary condensed away (oldest first):
|
|
112
|
+
// only while the lookback still reaches the summary.
|
|
113
|
+
const openingMessage = messages[opening];
|
|
114
|
+
const carried = (
|
|
115
|
+
start === opening && openingMessage
|
|
116
|
+
? (turnCarryOf(openingMessage) ?? [])
|
|
117
|
+
: []
|
|
118
|
+
).filter(
|
|
119
|
+
(call) =>
|
|
120
|
+
call.name === toolName && canonicalArgsKey(call.args) === argsKey,
|
|
121
|
+
);
|
|
55
122
|
|
|
56
|
-
|
|
123
|
+
const failedInTurn = (priorError: string): ToolMessage => {
|
|
57
124
|
logger.warn(
|
|
58
125
|
`Repetition guard: short-circuiting duplicate failed call to ${toolName}`,
|
|
59
126
|
{ toolName, priorError },
|
|
@@ -72,13 +139,129 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
72
139
|
name: toolName,
|
|
73
140
|
status: 'error',
|
|
74
141
|
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (let i = messages.length - 1; i >= start; i--) {
|
|
145
|
+
const msg = messages[i];
|
|
146
|
+
if (!(msg instanceof ToolMessage)) continue;
|
|
147
|
+
if (msg.status !== 'error') continue;
|
|
148
|
+
// The capability gate refused it without running it; after a
|
|
149
|
+
// `load_capability` the same call is the expected next step.
|
|
150
|
+
if (isCapabilityGateRefusal(msg)) continue;
|
|
151
|
+
if (msg.name !== toolName) continue;
|
|
152
|
+
|
|
153
|
+
const priorArgs = argsById.get(msg.tool_call_id);
|
|
154
|
+
if (priorArgs === undefined) continue;
|
|
155
|
+
if (canonicalArgsKey(priorArgs) !== argsKey) continue;
|
|
156
|
+
|
|
157
|
+
return failedInTurn(toolMessageText(msg));
|
|
158
|
+
}
|
|
159
|
+
const carriedFailure = carried.findLast(
|
|
160
|
+
(call) => call.status === 'error',
|
|
161
|
+
);
|
|
162
|
+
if (carriedFailure) return failedInTurn(carriedFailure.result);
|
|
163
|
+
|
|
164
|
+
// Identical calls that succeeded in this turn, plus identical calls
|
|
165
|
+
// earlier in this same model response (still running beside this one).
|
|
166
|
+
let identical = 0;
|
|
167
|
+
let lastResult: string | undefined;
|
|
168
|
+
for (const call of carried) {
|
|
169
|
+
if (call.status === 'error') continue;
|
|
170
|
+
identical += 1;
|
|
171
|
+
lastResult = call.result;
|
|
172
|
+
}
|
|
173
|
+
for (let i = start; i < messages.length; i++) {
|
|
174
|
+
const msg = messages[i];
|
|
175
|
+
if (!(msg instanceof ToolMessage)) continue;
|
|
176
|
+
if (msg.status === 'error' || msg.name !== toolName) continue;
|
|
177
|
+
const priorArgs = argsById.get(msg.tool_call_id);
|
|
178
|
+
if (priorArgs === undefined) continue;
|
|
179
|
+
if (canonicalArgsKey(priorArgs) !== argsKey) continue;
|
|
180
|
+
identical += 1;
|
|
181
|
+
lastResult = toolMessageText(msg);
|
|
182
|
+
}
|
|
183
|
+
for (const sibling of stepCallsBefore(messages, toolCall.id, start)) {
|
|
184
|
+
if (
|
|
185
|
+
sibling.name === toolName &&
|
|
186
|
+
canonicalArgsKey(sibling.args) === argsKey
|
|
187
|
+
)
|
|
188
|
+
identical += 1;
|
|
75
189
|
}
|
|
190
|
+
const effect = options.effectOf?.(toolName) ?? 'read';
|
|
191
|
+
const cap = effect === 'write' ? maxWrites : maxReads;
|
|
192
|
+
if (identical < cap) return handler(toolCallRequest);
|
|
76
193
|
|
|
77
|
-
|
|
194
|
+
logger.warn(
|
|
195
|
+
`Repetition guard: ${toolName} already ran ${identical} time(s) this turn with these exact arguments (${effect} cap ${cap}); not run again`,
|
|
196
|
+
);
|
|
197
|
+
const earlier =
|
|
198
|
+
lastResult !== undefined
|
|
199
|
+
? ['', 'It returned:', '', excerpt(lastResult)]
|
|
200
|
+
: [];
|
|
201
|
+
return new ToolMessage({
|
|
202
|
+
content: (effect === 'write'
|
|
203
|
+
? [
|
|
204
|
+
lastResult !== undefined
|
|
205
|
+
? `\`${toolName}\` already ran with these exact arguments in this turn, so it was NOT run again: running it twice would repeat its effect.`
|
|
206
|
+
: `An identical \`${toolName}\` call is already part of this step, so this one was NOT run: running it twice would repeat its effect.`,
|
|
207
|
+
...earlier,
|
|
208
|
+
'',
|
|
209
|
+
'Use that outcome. If the user wants it done again, confirm with them — they can ask again in a new message.',
|
|
210
|
+
]
|
|
211
|
+
: [
|
|
212
|
+
`You have already called \`${toolName}\` with these exact arguments ${identical} times in this turn, so it was NOT called again.`,
|
|
213
|
+
...earlier,
|
|
214
|
+
'',
|
|
215
|
+
'Use the result you have, change the arguments, or pick a different tool.',
|
|
216
|
+
]
|
|
217
|
+
).join('\n'),
|
|
218
|
+
tool_call_id: toolCall.id ?? '',
|
|
219
|
+
name: toolName,
|
|
220
|
+
status: 'error',
|
|
221
|
+
});
|
|
78
222
|
},
|
|
79
223
|
});
|
|
80
224
|
};
|
|
81
225
|
|
|
226
|
+
const EXCERPT_CHARS = 800;
|
|
227
|
+
|
|
228
|
+
function excerpt(text: string): string {
|
|
229
|
+
return text.length <= EXCERPT_CHARS
|
|
230
|
+
? text
|
|
231
|
+
: `${text.slice(0, EXCERPT_CHARS)}… (${text.length - EXCERPT_CHARS} more characters)`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Tool call id → arguments for every AI tool call from `start` on. */
|
|
235
|
+
function toolCallArgsById(
|
|
236
|
+
messages: readonly BaseMessage[],
|
|
237
|
+
start: number,
|
|
238
|
+
): Map<string, unknown> {
|
|
239
|
+
const out = new Map<string, unknown>();
|
|
240
|
+
for (let i = start; i < messages.length; i++) {
|
|
241
|
+
const m = messages[i];
|
|
242
|
+
if (!m || !AIMessage.isInstance(m)) continue;
|
|
243
|
+
for (const tc of m.tool_calls ?? []) if (tc.id) out.set(tc.id, tc.args);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The calls that precede `callId` in the model response that issued it. */
|
|
249
|
+
function stepCallsBefore(
|
|
250
|
+
messages: readonly BaseMessage[],
|
|
251
|
+
callId: string | undefined,
|
|
252
|
+
start: number,
|
|
253
|
+
): Array<{ name: string; args: unknown }> {
|
|
254
|
+
if (!callId) return [];
|
|
255
|
+
for (let i = messages.length - 1; i >= start; i--) {
|
|
256
|
+
const m = messages[i];
|
|
257
|
+
if (!m || !AIMessage.isInstance(m)) continue;
|
|
258
|
+
const calls = m.tool_calls ?? [];
|
|
259
|
+
const at = calls.findIndex((tc) => tc.id === callId);
|
|
260
|
+
if (at >= 0) return calls.slice(0, at);
|
|
261
|
+
}
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
|
|
82
265
|
function canonicalArgsKey(args: unknown): string {
|
|
83
266
|
if (args === null || args === undefined) return 'null';
|
|
84
267
|
if (typeof args !== 'object') return JSON.stringify(args);
|
|
@@ -107,19 +290,3 @@ function toolMessageText(msg: ToolMessage): string {
|
|
|
107
290
|
}
|
|
108
291
|
return '';
|
|
109
292
|
}
|
|
110
|
-
|
|
111
|
-
function findToolCallArgsById(
|
|
112
|
-
messages: BaseMessage[],
|
|
113
|
-
callId: string | undefined,
|
|
114
|
-
): unknown {
|
|
115
|
-
if (!callId) return undefined;
|
|
116
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
117
|
-
const m = messages[i];
|
|
118
|
-
if (!AIMessage.isInstance(m)) continue;
|
|
119
|
-
const toolCalls = m.tool_calls;
|
|
120
|
-
if (!toolCalls) continue;
|
|
121
|
-
const hit = toolCalls.find((tc) => tc.id === callId);
|
|
122
|
-
if (hit) return hit.args;
|
|
123
|
-
}
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the current turn starts in a thread's messages, and what a mid-turn
|
|
3
|
+
* summary keeps of the turn it condensed.
|
|
4
|
+
*
|
|
5
|
+
* A turn opens at the latest human message the user (or, in a sub-agent,
|
|
6
|
+
* the dispatching agent) wrote. The runtime writes human messages of its
|
|
7
|
+
* own — the summary that replaces a condensed history, the re-attachment
|
|
8
|
+
* `view_attachment` appends — and neither opens a turn.
|
|
9
|
+
*
|
|
10
|
+
* The summarizer can fire between two model steps of one turn and replace
|
|
11
|
+
* everything but the last few messages, the turn's opening message and its
|
|
12
|
+
* earlier tool calls included. The summary it writes then carries the
|
|
13
|
+
* calls of the turn it removed (`turn_carry`) and stands in as the turn's
|
|
14
|
+
* opening, so the repetition guard still knows what already ran this turn.
|
|
15
|
+
* A summary written at a turn boundary carries nothing and opens nothing.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
AIMessage,
|
|
19
|
+
HumanMessage,
|
|
20
|
+
ToolMessage,
|
|
21
|
+
type BaseMessage,
|
|
22
|
+
} from '@langchain/core/messages';
|
|
23
|
+
import { isAttachmentViewMessage } from '../../attachments/retention';
|
|
24
|
+
import { isCapabilityGateRefusal } from './capability-gate';
|
|
25
|
+
|
|
26
|
+
export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `true` for the condensed-history message the summarization middleware
|
|
30
|
+
* writes into graph state. List endpoints use this to keep it out of the
|
|
31
|
+
* user-visible transcript.
|
|
32
|
+
*/
|
|
33
|
+
export function isSummarizationMessage(message: BaseMessage): boolean {
|
|
34
|
+
if (message.additional_kwargs?.lc_source === 'summarization') return true;
|
|
35
|
+
const { content } = message;
|
|
36
|
+
return typeof content === 'string' && content.startsWith(SUMMARY_PREFIX);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A tool call of the current turn that a mid-turn summary condensed away. */
|
|
40
|
+
export interface CarriedToolCall {
|
|
41
|
+
name: string;
|
|
42
|
+
args: unknown;
|
|
43
|
+
/** `error` when its result was an error (the capability gate's refusals are not carried). */
|
|
44
|
+
status: 'success' | 'error';
|
|
45
|
+
/** The start of its result, for the repetition guard to quote. */
|
|
46
|
+
result: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `additional_kwargs` key of the calls a mid-turn summary carries. */
|
|
50
|
+
const TURN_CARRY = 'turn_carry';
|
|
51
|
+
|
|
52
|
+
/** Characters of a carried result kept (the guard quotes at most this much). */
|
|
53
|
+
export const CARRIED_RESULT_CHARS = 800;
|
|
54
|
+
|
|
55
|
+
/** The calls `message` carries when it is a summary written mid-turn, else `undefined`. */
|
|
56
|
+
export function turnCarryOf(
|
|
57
|
+
message: BaseMessage,
|
|
58
|
+
): CarriedToolCall[] | undefined {
|
|
59
|
+
if (!isSummarizationMessage(message)) return undefined;
|
|
60
|
+
const raw: unknown = message.additional_kwargs?.[TURN_CARRY];
|
|
61
|
+
if (!Array.isArray(raw)) return undefined;
|
|
62
|
+
return raw.flatMap((entry: unknown): CarriedToolCall[] => {
|
|
63
|
+
if (!entry || typeof entry !== 'object') return [];
|
|
64
|
+
const name: unknown = Reflect.get(entry, 'name');
|
|
65
|
+
const status: unknown = Reflect.get(entry, 'status');
|
|
66
|
+
const result: unknown = Reflect.get(entry, 'result');
|
|
67
|
+
if (typeof name !== 'string') return [];
|
|
68
|
+
if (status !== 'success' && status !== 'error') return [];
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
name,
|
|
72
|
+
args: Reflect.get(entry, 'args'),
|
|
73
|
+
status,
|
|
74
|
+
result: typeof result === 'string' ? result : '',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Index of the message that opens the current turn: the latest human
|
|
82
|
+
* message other than the ones the runtime writes itself, or a summary that
|
|
83
|
+
* carries the calls of a turn it condensed mid-way. 0 when there is none.
|
|
84
|
+
*/
|
|
85
|
+
export function turnStart(messages: readonly BaseMessage[]): number {
|
|
86
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
87
|
+
const message = messages[i];
|
|
88
|
+
if (!message || !HumanMessage.isInstance(message)) continue;
|
|
89
|
+
if (isSummarizationMessage(message)) {
|
|
90
|
+
if (turnCarryOf(message) !== undefined) return i;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!isAttachmentViewMessage(message)) return i;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resultText(message: ToolMessage): string {
|
|
99
|
+
const { content } = message;
|
|
100
|
+
const text =
|
|
101
|
+
typeof content === 'string'
|
|
102
|
+
? content
|
|
103
|
+
: content
|
|
104
|
+
.map((block) =>
|
|
105
|
+
typeof block === 'string'
|
|
106
|
+
? block
|
|
107
|
+
: 'text' in block && typeof block.text === 'string'
|
|
108
|
+
? block.text
|
|
109
|
+
: '',
|
|
110
|
+
)
|
|
111
|
+
.join('\n');
|
|
112
|
+
return text.length <= CARRIED_RESULT_CHARS
|
|
113
|
+
? text
|
|
114
|
+
: `${text.slice(0, CARRIED_RESULT_CHARS)}…`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The calls of the current turn that a summary of `before` would remove: the
|
|
119
|
+
* turn's opening is among the removed messages (it is not in `keptIds`), so
|
|
120
|
+
* the turn continues after the summary. `undefined` when the summary falls
|
|
121
|
+
* on a turn boundary (the whole current turn is kept). Calls an earlier
|
|
122
|
+
* mid-turn summary of this same turn carried are carried on.
|
|
123
|
+
*/
|
|
124
|
+
export function carriedCallsOfTurn(
|
|
125
|
+
before: readonly BaseMessage[],
|
|
126
|
+
keptIds: ReadonlySet<string>,
|
|
127
|
+
): CarriedToolCall[] | undefined {
|
|
128
|
+
const start = turnStart(before);
|
|
129
|
+
const opening = before[start];
|
|
130
|
+
if (!opening || !HumanMessage.isInstance(opening)) return undefined;
|
|
131
|
+
if (opening.id !== undefined && keptIds.has(opening.id)) return undefined;
|
|
132
|
+
const carried: CarriedToolCall[] = [...(turnCarryOf(opening) ?? [])];
|
|
133
|
+
const calls = new Map<string, { name: string; args: unknown }>();
|
|
134
|
+
for (let i = start; i < before.length; i += 1) {
|
|
135
|
+
const message = before[i];
|
|
136
|
+
if (!message || (message.id !== undefined && keptIds.has(message.id)))
|
|
137
|
+
continue;
|
|
138
|
+
if (AIMessage.isInstance(message)) {
|
|
139
|
+
for (const call of message.tool_calls ?? [])
|
|
140
|
+
if (call.id) calls.set(call.id, { name: call.name, args: call.args });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!ToolMessage.isInstance(message) || isCapabilityGateRefusal(message))
|
|
144
|
+
continue;
|
|
145
|
+
const call = calls.get(message.tool_call_id);
|
|
146
|
+
if (!call) continue;
|
|
147
|
+
carried.push({
|
|
148
|
+
name: message.name ?? call.name,
|
|
149
|
+
args: call.args,
|
|
150
|
+
status: message.status === 'error' ? 'error' : 'success',
|
|
151
|
+
result: resultText(message),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return carried;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `additional_kwargs` that make a summary carry `calls` (see `turnCarryOf`). */
|
|
158
|
+
export function turnCarryKwargs(
|
|
159
|
+
calls: readonly CarriedToolCall[],
|
|
160
|
+
): Record<string, unknown> {
|
|
161
|
+
return { [TURN_CARRY]: calls.map((call) => ({ ...call })) };
|
|
162
|
+
}
|
|
@@ -11,12 +11,28 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Per isolate rather than per process: a cold isolate pays one ~100 ms fetch
|
|
13
13
|
* on its first `/models`, then serves from memory until evicted.
|
|
14
|
+
*
|
|
15
|
+
* Every turn reads it (the context-window resolver), so it never holds a
|
|
16
|
+
* turn up for long: the fetch is cut off after `OPENROUTER_FETCH_TIMEOUT_MS`,
|
|
17
|
+
* concurrent callers share one fetch, an expired listing is served while a
|
|
18
|
+
* fresh one is fetched in the background, and a failure is remembered for
|
|
19
|
+
* `OPENROUTER_FAILURE_TTL_MS` before the next attempt.
|
|
14
20
|
*/
|
|
15
21
|
import { z } from 'zod';
|
|
16
22
|
import type { ModelPrice } from './llm';
|
|
17
23
|
|
|
18
24
|
export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
19
25
|
export const OPENROUTER_PRICE_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
26
|
+
/** How long a `/models` fetch (response and body) may take before it counts as failed. */
|
|
27
|
+
export const OPENROUTER_FETCH_TIMEOUT_MS = 3_000;
|
|
28
|
+
/** How long a failed fetch is remembered before the next one is attempted. */
|
|
29
|
+
export const OPENROUTER_FAILURE_TTL_MS = 60_000;
|
|
30
|
+
/**
|
|
31
|
+
* Past its timeout plus this, a shared fetch that never settled (the runtime
|
|
32
|
+
* dropped the request that started it, its timer with it) is abandoned and
|
|
33
|
+
* the next caller starts a new one.
|
|
34
|
+
*/
|
|
35
|
+
export const OPENROUTER_INFLIGHT_MARGIN_MS = 1_000;
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* OpenRouter returns `pricing.prompt` / `pricing.completion` as strings in USD
|
|
@@ -52,6 +68,20 @@ interface PriceCache {
|
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
let cache: PriceCache | null = null;
|
|
71
|
+
interface InflightFetch {
|
|
72
|
+
done: Promise<void>;
|
|
73
|
+
/** When it started, by the caller's clock. */
|
|
74
|
+
startedAt: number;
|
|
75
|
+
/** How long it may stay shared (its timeout plus the margin). */
|
|
76
|
+
limitMs: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The fetch in flight, shared by every caller that needs it. */
|
|
80
|
+
let inflight: InflightFetch | null = null;
|
|
81
|
+
/** When the last fetch failed (by the caller's clock); `null` after a success. */
|
|
82
|
+
let failedAt: number | null = null;
|
|
83
|
+
/** Bumped by a reset: a refresh started before it settles without writing. */
|
|
84
|
+
let generation = 0;
|
|
55
85
|
|
|
56
86
|
function parseContextLengths(
|
|
57
87
|
data: z.infer<typeof openRouterModelsSchema>['data'],
|
|
@@ -96,33 +126,62 @@ export interface FetchOpenRouterPricesOptions {
|
|
|
96
126
|
fetch?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
97
127
|
now?: () => number;
|
|
98
128
|
logger?: { warn: (message: string) => void };
|
|
129
|
+
/** Fetch timeout (default `OPENROUTER_FETCH_TIMEOUT_MS`). */
|
|
130
|
+
timeoutMs?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Keeps a background refresh alive after the caller has its answer
|
|
133
|
+
* (`ctx.waitUntil` on Workers). Without it the refresh is merely detached.
|
|
134
|
+
*/
|
|
135
|
+
waitUntil?: (work: Promise<unknown>) => void;
|
|
99
136
|
}
|
|
100
137
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
138
|
+
/** `work`, or a rejection once `ms` have passed (the timer never outlives it). */
|
|
139
|
+
function withTimeout<T>(
|
|
140
|
+
work: Promise<T>,
|
|
141
|
+
ms: number,
|
|
142
|
+
controller: AbortController,
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
145
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
146
|
+
timer = setTimeout(() => {
|
|
147
|
+
const error = new Error(`OpenRouter /models timed out after ${ms} ms`);
|
|
148
|
+
error.name = 'TimeoutError';
|
|
149
|
+
controller.abort(error);
|
|
150
|
+
reject(error);
|
|
151
|
+
}, ms);
|
|
152
|
+
});
|
|
153
|
+
return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One fetch of the listing into `cache`; a failure is logged and remembered, never thrown. */
|
|
157
|
+
async function refresh(opts: FetchOpenRouterPricesOptions): Promise<void> {
|
|
109
158
|
const now = opts.now ?? Date.now;
|
|
110
|
-
if (cache && now() - cache.fetchedAt < OPENROUTER_PRICE_CACHE_TTL_MS) {
|
|
111
|
-
return cache.prices;
|
|
112
|
-
}
|
|
113
159
|
// The global `fetch` must be called as a function of the global scope on
|
|
114
160
|
// workerd ("Illegal invocation" when handed around as a bare reference).
|
|
115
161
|
const fetchImpl =
|
|
116
162
|
opts.fetch ?? ((input: string, init?: RequestInit) => fetch(input, init));
|
|
117
163
|
const logger = opts.logger ?? console;
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const startedIn = generation;
|
|
118
166
|
try {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
const parsed = await withTimeout(
|
|
168
|
+
(async () => {
|
|
169
|
+
const response = await fetchImpl(OPENROUTER_MODELS_URL, {
|
|
170
|
+
headers: opts.apiKey
|
|
171
|
+
? { Authorization: `Bearer ${opts.apiKey}` }
|
|
172
|
+
: {},
|
|
173
|
+
signal: controller.signal,
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`OpenRouter /models returned HTTP ${response.status}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return openRouterModelsSchema.safeParse(await response.json());
|
|
181
|
+
})(),
|
|
182
|
+
opts.timeoutMs ?? OPENROUTER_FETCH_TIMEOUT_MS,
|
|
183
|
+
controller,
|
|
184
|
+
);
|
|
126
185
|
if (!parsed.success) {
|
|
127
186
|
throw new Error(`Unexpected /models payload: ${parsed.error.message}`);
|
|
128
187
|
}
|
|
@@ -130,19 +189,87 @@ export async function fetchOpenRouterPrices(
|
|
|
130
189
|
if (prices.size === 0) {
|
|
131
190
|
throw new Error('OpenRouter /models returned no usable pricing');
|
|
132
191
|
}
|
|
192
|
+
if (generation !== startedIn) return;
|
|
133
193
|
cache = {
|
|
134
194
|
fetchedAt: now(),
|
|
135
195
|
prices,
|
|
136
196
|
contextLengths: parseContextLengths(parsed.data.data),
|
|
137
197
|
};
|
|
138
|
-
|
|
198
|
+
failedAt = null;
|
|
139
199
|
} catch (error) {
|
|
200
|
+
if (generation !== startedIn) return;
|
|
201
|
+
failedAt = now();
|
|
140
202
|
logger.warn(
|
|
141
|
-
`[models] live OpenRouter pricing unavailable — serving baseline prices: ${
|
|
203
|
+
`[models] live OpenRouter pricing unavailable — serving ${cache ? 'the last listing' : 'baseline prices'}: ${
|
|
142
204
|
error instanceof Error ? error.message : String(error)
|
|
143
205
|
}`,
|
|
144
206
|
);
|
|
145
|
-
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Live OpenRouter prices keyed by model id ($/million tokens). Cached for an
|
|
212
|
+
* hour. Never throws: on any failure it returns the last cached map, or an
|
|
213
|
+
* empty map (so the caller falls back to the catalog's baseline prices).
|
|
214
|
+
* Waits at most the fetch timeout, and only when nothing is cached.
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchOpenRouterPrices(
|
|
217
|
+
opts: FetchOpenRouterPricesOptions = {},
|
|
218
|
+
): Promise<ReadonlyMap<string, ModelPrice>> {
|
|
219
|
+
const now = opts.now ?? Date.now;
|
|
220
|
+
if (cache && now() - cache.fetchedAt < OPENROUTER_PRICE_CACHE_TTL_MS) {
|
|
221
|
+
return cache.prices;
|
|
222
|
+
}
|
|
223
|
+
// A clock that went backwards does not keep a failure remembered.
|
|
224
|
+
const sinceFailure = failedAt === null ? null : now() - failedAt;
|
|
225
|
+
const failedRecently =
|
|
226
|
+
sinceFailure !== null &&
|
|
227
|
+
sinceFailure >= 0 &&
|
|
228
|
+
sinceFailure < OPENROUTER_FAILURE_TTL_MS;
|
|
229
|
+
if (!failedRecently) {
|
|
230
|
+
const limitMs =
|
|
231
|
+
(opts.timeoutMs ?? OPENROUTER_FETCH_TIMEOUT_MS) +
|
|
232
|
+
OPENROUTER_INFLIGHT_MARGIN_MS;
|
|
233
|
+
if (inflight) {
|
|
234
|
+
const age = now() - inflight.startedAt;
|
|
235
|
+
if (age < 0 || age > inflight.limitMs) inflight = null;
|
|
236
|
+
}
|
|
237
|
+
const shared = inflight ?? startRefresh(opts, now(), limitMs);
|
|
238
|
+
if (!cache) await settledWithin(shared.done, limitMs);
|
|
239
|
+
else opts.waitUntil?.(shared.done);
|
|
240
|
+
}
|
|
241
|
+
return cache?.prices ?? new Map<string, ModelPrice>();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function startRefresh(
|
|
245
|
+
opts: FetchOpenRouterPricesOptions,
|
|
246
|
+
startedAt: number,
|
|
247
|
+
limitMs: number,
|
|
248
|
+
): InflightFetch {
|
|
249
|
+
const entry: InflightFetch = {
|
|
250
|
+
done: refresh(opts).finally(() => {
|
|
251
|
+
// An abandoned fetch that settles late leaves its successor alone.
|
|
252
|
+
if (inflight === entry) inflight = null;
|
|
253
|
+
}),
|
|
254
|
+
startedAt,
|
|
255
|
+
limitMs,
|
|
256
|
+
};
|
|
257
|
+
inflight = entry;
|
|
258
|
+
return entry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Waits for `work`, or for `ms` on this caller's own timer, whichever is first. */
|
|
262
|
+
async function settledWithin(work: Promise<void>, ms: number): Promise<void> {
|
|
263
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
+
try {
|
|
265
|
+
await Promise.race([
|
|
266
|
+
work,
|
|
267
|
+
new Promise<void>((resolve) => {
|
|
268
|
+
timer = setTimeout(resolve, ms);
|
|
269
|
+
}),
|
|
270
|
+
]);
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
146
273
|
}
|
|
147
274
|
}
|
|
148
275
|
|
|
@@ -159,7 +286,13 @@ export async function fetchOpenRouterContextLengths(
|
|
|
159
286
|
return cache?.contextLengths ?? new Map<string, number>();
|
|
160
287
|
}
|
|
161
288
|
|
|
162
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* Test seam: clear the in-memory price cache, the shared fetch and the
|
|
291
|
+
* remembered failure. A refresh still running settles without writing.
|
|
292
|
+
*/
|
|
163
293
|
export function resetOpenRouterPriceCache(): void {
|
|
164
294
|
cache = null;
|
|
295
|
+
inflight = null;
|
|
296
|
+
failedAt = null;
|
|
297
|
+
generation += 1;
|
|
165
298
|
}
|