@agentex/agent 0.0.34 → 0.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +185 -0
- package/README.md +2 -1
- package/dist/providers/claude/discovery.d.ts +64 -0
- package/dist/providers/claude/discovery.d.ts.map +1 -0
- package/dist/providers/claude/discovery.js +147 -0
- package/dist/providers/claude/discovery.js.map +1 -0
- package/dist/providers/claude/effort.d.ts +20 -0
- package/dist/providers/claude/effort.d.ts.map +1 -0
- package/dist/providers/claude/effort.js +32 -0
- package/dist/providers/claude/effort.js.map +1 -0
- package/dist/providers/claude/execute.d.ts.map +1 -1
- package/dist/providers/claude/execute.js +2 -1
- package/dist/providers/claude/execute.js.map +1 -1
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +2 -1
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/parse.d.ts.map +1 -1
- package/dist/providers/claude/parse.js +26 -4
- package/dist/providers/claude/parse.js.map +1 -1
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +2 -1
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/discovery.d.ts +20 -0
- package/dist/providers/codex/discovery.d.ts.map +1 -0
- package/dist/providers/codex/discovery.js +76 -0
- package/dist/providers/codex/discovery.js.map +1 -0
- package/dist/providers/codex/execute.d.ts.map +1 -1
- package/dist/providers/codex/execute.js +45 -3
- package/dist/providers/codex/execute.js.map +1 -1
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +2 -1
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/parse.d.ts +14 -6
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +51 -16
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/session.d.ts +29 -1
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +255 -57
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/cursor/discovery.d.ts.map +1 -1
- package/dist/providers/cursor/discovery.js +4 -0
- package/dist/providers/cursor/discovery.js.map +1 -1
- package/dist/providers/opencode/discovery.d.ts.map +1 -1
- package/dist/providers/opencode/discovery.js +11 -8
- package/dist/providers/opencode/discovery.js.map +1 -1
- package/dist/providers/opencode/event-parse.d.ts +23 -0
- package/dist/providers/opencode/event-parse.d.ts.map +1 -1
- package/dist/providers/opencode/event-parse.js +44 -0
- package/dist/providers/opencode/event-parse.js.map +1 -1
- package/dist/providers/opencode/history.d.ts.map +1 -1
- package/dist/providers/opencode/history.js +25 -4
- package/dist/providers/opencode/history.js.map +1 -1
- package/dist/providers/opencode/http-session.d.ts.map +1 -1
- package/dist/providers/opencode/http-session.js +59 -8
- package/dist/providers/opencode/http-session.js.map +1 -1
- package/dist/types.d.ts +19 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/model-cache.d.ts +33 -0
- package/dist/utils/model-cache.d.ts.map +1 -0
- package/dist/utils/model-cache.js +57 -0
- package/dist/utils/model-cache.js.map +1 -0
- package/package.json +1 -1
- package/src/providers/claude/discovery.ts +215 -0
- package/src/providers/claude/effort.ts +34 -0
- package/src/providers/claude/execute.ts +2 -1
- package/src/providers/claude/index.ts +2 -1
- package/src/providers/claude/parse.ts +27 -5
- package/src/providers/claude/session.ts +2 -1
- package/src/providers/codex/discovery.ts +91 -0
- package/src/providers/codex/execute.ts +42 -3
- package/src/providers/codex/index.ts +2 -1
- package/src/providers/codex/parse.ts +60 -18
- package/src/providers/codex/session.ts +285 -59
- package/src/providers/cursor/discovery.ts +5 -0
- package/src/providers/opencode/discovery.ts +10 -7
- package/src/providers/opencode/event-parse.ts +63 -0
- package/src/providers/opencode/history.ts +32 -4
- package/src/providers/opencode/http-session.ts +59 -8
- package/src/types.ts +19 -5
- package/src/utils/model-cache.ts +67 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ListModelsOptions, ProviderModel } from "../../types.js";
|
|
2
2
|
import { acquireOpenCodeRuntime } from "./runtime.js";
|
|
3
|
+
import { withModelCache } from "../../utils/model-cache.js";
|
|
3
4
|
|
|
4
5
|
function record(value: unknown): Record<string, unknown> {
|
|
5
6
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -59,11 +60,13 @@ export function openCodeModelsFromPayload(payload: Record<string, unknown>): Pro
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function listOpenCodeModels(options: ListModelsOptions = {}): Promise<ProviderModel[]> {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
return withModelCache("opencode", options, options.cacheTtlMs, async () => {
|
|
64
|
+
const runtime = await acquireOpenCodeRuntime(options);
|
|
65
|
+
try {
|
|
66
|
+
const payload = await runtime.server.client.json<Record<string, unknown>>("/provider");
|
|
67
|
+
return openCodeModelsFromPayload(payload);
|
|
68
|
+
} finally {
|
|
69
|
+
runtime.server.release();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
69
72
|
}
|
|
@@ -54,6 +54,69 @@ export function turnStatusFromMessage(info: unknown): "completed" | "failed" {
|
|
|
54
54
|
return m && m["error"] ? "failed" : "completed";
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Finish reasons that represent the model deliberately ending its turn. Anything
|
|
59
|
+
* outside this set that also produced no text is treated as an incomplete turn
|
|
60
|
+
* (see `terminalOutcome`). `tool-calls` is included because it's an intermediate
|
|
61
|
+
* step in the agent loop, not an empty terminal turn.
|
|
62
|
+
*/
|
|
63
|
+
const CLEAN_FINISH_REASONS = new Set(["stop", "length", "end_turn", "stop_sequence", "tool-calls"]);
|
|
64
|
+
|
|
65
|
+
/** User-facing note for a turn that ended with no reply (provider dropped the stream). */
|
|
66
|
+
export function incompleteTurnMessage(finish: string | null): string {
|
|
67
|
+
const reason = finish ? ` (finish reason "${finish}")` : "";
|
|
68
|
+
return (
|
|
69
|
+
`The model ended the turn without sending a reply${reason}. ` +
|
|
70
|
+
"The upstream provider returned no content, which usually means the stream was dropped. " +
|
|
71
|
+
"Send your message again to retry."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TerminalOutcome {
|
|
76
|
+
status: "completed" | "failed";
|
|
77
|
+
errorCode: string | null;
|
|
78
|
+
errorMessage: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* True when the turn ended with no reply, no tool output, and no error — the
|
|
81
|
+
* "silent empty turn" opencode records as a normal completion (typically after
|
|
82
|
+
* an upstream stream error, surfaced as `finish: "unknown"`). Distinct from a
|
|
83
|
+
* user interrupt, which opencode marks with a `MessageAbortedError` in
|
|
84
|
+
* `info.error` and so lands in the error branch below.
|
|
85
|
+
*/
|
|
86
|
+
incomplete: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classify a terminal assistant message. Extends `turnStatusFromMessage` by
|
|
91
|
+
* catching the empty-turn case so a dropped stream surfaces as `failed` (with a
|
|
92
|
+
* visible note) instead of rendering as a stall. Keyed on the message having no
|
|
93
|
+
* visible text and a non-clean finish reason; never fires when the model
|
|
94
|
+
* produced an answer or stopped cleanly, and never fires on an interrupt.
|
|
95
|
+
*/
|
|
96
|
+
export function terminalOutcome(info: unknown, hasVisibleText: boolean): TerminalOutcome {
|
|
97
|
+
const status = turnStatusFromMessage(info);
|
|
98
|
+
if (status === "failed") {
|
|
99
|
+
const m = rec(info);
|
|
100
|
+
return {
|
|
101
|
+
status,
|
|
102
|
+
errorCode: "agent_error",
|
|
103
|
+
errorMessage: JSON.stringify(m?.["error"] ?? null),
|
|
104
|
+
incomplete: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const finish = str(rec(info)?.["finish"] ?? null);
|
|
108
|
+
const cleanStop = finish !== null && CLEAN_FINISH_REASONS.has(finish);
|
|
109
|
+
if (!hasVisibleText && !cleanStop) {
|
|
110
|
+
return {
|
|
111
|
+
status: "failed",
|
|
112
|
+
errorCode: "incomplete_turn",
|
|
113
|
+
errorMessage: incompleteTurnMessage(finish),
|
|
114
|
+
incomplete: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { status: "completed", errorCode: null, errorMessage: null, incomplete: false };
|
|
118
|
+
}
|
|
119
|
+
|
|
57
120
|
/** Map opencode's `tokens` (+ model identity) to agentex usage. */
|
|
58
121
|
export function usageFromMessage(info: unknown): Record<string, TokenUsage> | undefined {
|
|
59
122
|
const m = rec(info);
|
|
@@ -15,7 +15,14 @@ import { assertSessionRecord, createSessionRecord, MalformedSessionRecordError }
|
|
|
15
15
|
import { acquireOpenCodeRuntime } from "./runtime.js";
|
|
16
16
|
import { opencodeSessionCodec } from "./codec.js";
|
|
17
17
|
import { createOpenCodeSession } from "./http-session.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
assistantTextFromParts,
|
|
20
|
+
incompleteTurnMessage,
|
|
21
|
+
mapOpenCodePart,
|
|
22
|
+
mapOpenCodeToolCall,
|
|
23
|
+
terminalOutcome,
|
|
24
|
+
type OcBaseInfo,
|
|
25
|
+
} from "./event-parse.js";
|
|
19
26
|
|
|
20
27
|
const PAGE_SIZE = 100;
|
|
21
28
|
const MAX_PAGES = 100;
|
|
@@ -209,16 +216,37 @@ export function historicalEvents(
|
|
|
209
216
|
const lastPartId = events.at(-1)?.partId;
|
|
210
217
|
const finished = string(info["finish"]) !== null || info["error"] != null;
|
|
211
218
|
if (messageId && lastPartId && finished) {
|
|
212
|
-
const
|
|
219
|
+
const hasVisibleText = assistantTextFromParts(message.parts).trim().length > 0;
|
|
220
|
+
const term = terminalOutcome(info, hasVisibleText);
|
|
221
|
+
// A turn that ended with no reply (upstream dropped the stream) gets a
|
|
222
|
+
// visible note, deduped against the live session's note by the shared
|
|
223
|
+
// `:incomplete` event id.
|
|
224
|
+
if (term.incomplete) {
|
|
225
|
+
events.push({
|
|
226
|
+
partId: lastPartId,
|
|
227
|
+
event: {
|
|
228
|
+
type: "assistant",
|
|
229
|
+
text: incompleteTurnMessage(string(info["finish"])),
|
|
230
|
+
timestamp,
|
|
231
|
+
providerType: "opencode",
|
|
232
|
+
sessionId,
|
|
233
|
+
messageId,
|
|
234
|
+
eventId: `${messageId}:incomplete`,
|
|
235
|
+
turnId: null,
|
|
236
|
+
parentToolCallId: null,
|
|
237
|
+
raw: { synthetic: "incomplete_turn", info },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
213
241
|
events.push({
|
|
214
242
|
partId: lastPartId,
|
|
215
243
|
event: {
|
|
216
244
|
type: "result",
|
|
217
245
|
text: "",
|
|
218
246
|
costUsd: typeof info["cost"] === "number" ? info["cost"] : null,
|
|
219
|
-
isError:
|
|
247
|
+
isError: term.status !== "completed",
|
|
220
248
|
stopReason: null,
|
|
221
|
-
terminalReason:
|
|
249
|
+
terminalReason: term.status,
|
|
222
250
|
numTurns: 1,
|
|
223
251
|
durationMs: null,
|
|
224
252
|
timestamp,
|
|
@@ -25,9 +25,10 @@ import { opencodeSessionCodec } from "./codec.js";
|
|
|
25
25
|
import { prepareOpenCodeSkillConfig } from "./skill-config.js";
|
|
26
26
|
import {
|
|
27
27
|
assistantTextFromParts,
|
|
28
|
+
incompleteTurnMessage,
|
|
28
29
|
mapOpenCodePart,
|
|
29
30
|
mapOpenCodeToolCall,
|
|
30
|
-
|
|
31
|
+
terminalOutcome,
|
|
31
32
|
usageFromMessage,
|
|
32
33
|
type OcBaseInfo,
|
|
33
34
|
} from "./event-parse.js";
|
|
@@ -83,6 +84,12 @@ class OpenCodeSession implements AgentSession {
|
|
|
83
84
|
private readonly _seenTextLen = new Map<string, number>();
|
|
84
85
|
private readonly _emittedToolCall = new Set<string>();
|
|
85
86
|
private readonly _emittedToolResult = new Set<string>();
|
|
87
|
+
/**
|
|
88
|
+
* messageID → role, learned from `message.updated` SSE frames. Lets `emitPart`
|
|
89
|
+
* drop a user message's own text part, which opencode streams once a turn is
|
|
90
|
+
* active and would otherwise surface as an assistant bubble echoing the prompt.
|
|
91
|
+
*/
|
|
92
|
+
private readonly _messageRoles = new Map<string, string>();
|
|
86
93
|
private _sse: AbortController | null = null;
|
|
87
94
|
private readonly _handledInput = new Set<string>();
|
|
88
95
|
private readonly _inputInFlight = new Set<string>();
|
|
@@ -245,6 +252,17 @@ class OpenCodeSession implements AgentSession {
|
|
|
245
252
|
if (request) await this.handleQuestion(request);
|
|
246
253
|
return;
|
|
247
254
|
}
|
|
255
|
+
if (payload["type"] === "message.updated") {
|
|
256
|
+
// Track message roles so emitPart can distinguish a user message's own
|
|
257
|
+
// text part (echoed onto the SSE feed) from real assistant output.
|
|
258
|
+
const info = rec(rec(payload["properties"])?.["info"]) ?? rec(payload["properties"]);
|
|
259
|
+
const mid = info ? str(info["id"]) : null;
|
|
260
|
+
const role = info ? str(info["role"]) : null;
|
|
261
|
+
if (mid && role && str(info!["sessionID"]) === this._sessionId) {
|
|
262
|
+
this._messageRoles.set(mid, role);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
248
266
|
if (payload["type"] !== "message.part.updated") return;
|
|
249
267
|
const props = rec(payload["properties"]);
|
|
250
268
|
const part = rec(props?.["part"]);
|
|
@@ -403,6 +421,12 @@ class OpenCodeSession implements AgentSession {
|
|
|
403
421
|
// The SSE feed is global and async: drop stragglers that arrive while no turn
|
|
404
422
|
// is active so a finished turn's late parts don't surface against the next one.
|
|
405
423
|
if (!this._turnActive) return;
|
|
424
|
+
// Drop parts belonging to a user message — opencode re-streams the prompt's
|
|
425
|
+
// own text part once the turn is active, which would echo as an assistant
|
|
426
|
+
// bubble. Unknown role (message.updated not seen yet) falls through and
|
|
427
|
+
// emits, so a race can never suppress genuine assistant output.
|
|
428
|
+
const messageId = str(part["messageID"]);
|
|
429
|
+
if (messageId && this._messageRoles.get(messageId) === "user") return;
|
|
406
430
|
const info: OcBaseInfo = {
|
|
407
431
|
provider: "opencode",
|
|
408
432
|
sessionId: this._sessionId,
|
|
@@ -537,18 +561,20 @@ class OpenCodeSession implements AgentSession {
|
|
|
537
561
|
const data = (await res.json()) as Record<string, unknown>;
|
|
538
562
|
terminalRaw = data;
|
|
539
563
|
const info = rec(data["info"]);
|
|
540
|
-
const status = turnStatusFromMessage(info);
|
|
541
564
|
const usage = usageFromMessage(info);
|
|
542
565
|
const summary = assistantTextFromParts(data["parts"]) || null;
|
|
543
566
|
const costUsd = info ? num(info["cost"]) : null;
|
|
544
|
-
const
|
|
567
|
+
const term = terminalOutcome(info, summary != null && summary.trim().length > 0);
|
|
568
|
+
// A silent empty turn (upstream dropped the stream) would otherwise
|
|
569
|
+
// render as a stall. Surface a visible note before the terminal result.
|
|
570
|
+
if (term.incomplete && info) await this.emitIncompleteNote(info);
|
|
545
571
|
outcome = {
|
|
546
572
|
summary,
|
|
547
573
|
...(usage ? { usage } : {}),
|
|
548
574
|
costUsd,
|
|
549
|
-
status,
|
|
550
|
-
errorCode:
|
|
551
|
-
errorMessage,
|
|
575
|
+
status: term.status,
|
|
576
|
+
errorCode: term.errorCode,
|
|
577
|
+
errorMessage: term.errorMessage,
|
|
552
578
|
};
|
|
553
579
|
}
|
|
554
580
|
}
|
|
@@ -584,12 +610,35 @@ class OpenCodeSession implements AgentSession {
|
|
|
584
610
|
return outcome;
|
|
585
611
|
}
|
|
586
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Surface a turn that ended with no reply as a visible assistant note. The
|
|
615
|
+
* stable `:incomplete` event id is shared with the reconcile path
|
|
616
|
+
* (`historicalEvents`) so a later catch-up dedups against this live note
|
|
617
|
+
* instead of duplicating it.
|
|
618
|
+
*/
|
|
619
|
+
private async emitIncompleteNote(info: Record<string, unknown>): Promise<void> {
|
|
620
|
+
const messageId = str(info["id"]);
|
|
621
|
+
await this.emit({
|
|
622
|
+
type: "assistant",
|
|
623
|
+
text: incompleteTurnMessage(str(info["finish"])),
|
|
624
|
+
timestamp: new Date().toISOString(),
|
|
625
|
+
providerType: "opencode",
|
|
626
|
+
sessionId: this._sessionId,
|
|
627
|
+
messageId,
|
|
628
|
+
eventId: messageId ? `${messageId}:incomplete` : null,
|
|
629
|
+
turnId: null,
|
|
630
|
+
parentToolCallId: null,
|
|
631
|
+
raw: { synthetic: "incomplete_turn", info },
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
587
635
|
private async emitTerminal(
|
|
588
636
|
outcome: TurnResult,
|
|
589
637
|
raw: Record<string, unknown>,
|
|
590
638
|
startedAt: number,
|
|
591
639
|
): Promise<void> {
|
|
592
640
|
const info = rec(raw["info"]);
|
|
641
|
+
const messageId = info ? str(info["id"]) : null;
|
|
593
642
|
await this.emit({
|
|
594
643
|
type: "result",
|
|
595
644
|
text: outcome.summary ?? outcome.errorMessage ?? "",
|
|
@@ -602,8 +651,10 @@ class OpenCodeSession implements AgentSession {
|
|
|
602
651
|
timestamp: new Date().toISOString(),
|
|
603
652
|
providerType: "opencode",
|
|
604
653
|
sessionId: this._sessionId,
|
|
605
|
-
messageId
|
|
606
|
-
|
|
654
|
+
messageId,
|
|
655
|
+
// Match the reconcile path's `${messageId}:result` id so the live and
|
|
656
|
+
// catch-up terminal markers dedup instead of both landing as rows.
|
|
657
|
+
eventId: messageId ? `${messageId}:result` : null,
|
|
607
658
|
turnId: null,
|
|
608
659
|
parentToolCallId: null,
|
|
609
660
|
raw,
|
package/src/types.ts
CHANGED
|
@@ -1203,17 +1203,29 @@ export type StreamEvent =
|
|
|
1203
1203
|
* `phase: "completed"` settles only this task. It is never a root turn
|
|
1204
1204
|
* result and must not be used to resolve `SendHandle.result`.
|
|
1205
1205
|
*
|
|
1206
|
-
* Events are reducer-friendly snapshots
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
* is
|
|
1206
|
+
* Events are reducer-friendly snapshots, and every optional field follows one
|
|
1207
|
+
* rule: null means the provider did not report it, so keep what you already
|
|
1208
|
+
* have. That includes `status` — a sparse patch can rename a task without
|
|
1209
|
+
* saying anything about whether it is still running. `parentTaskId`
|
|
1210
|
+
* identifies a nested background task when that lineage is available.
|
|
1210
1211
|
*/
|
|
1211
1212
|
| ({
|
|
1212
1213
|
type: "background_task";
|
|
1213
1214
|
taskId: string;
|
|
1214
1215
|
taskType: BackgroundTaskType;
|
|
1215
1216
|
phase: BackgroundTaskPhase;
|
|
1216
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* The task's state as the provider reported it, or `null` for "no change
|
|
1219
|
+
* reported" — read it as "keep whatever you have".
|
|
1220
|
+
*
|
|
1221
|
+
* Null rather than a default because the alternative is asserting a state
|
|
1222
|
+
* the provider never sent. A sparse patch that only renames a task would
|
|
1223
|
+
* otherwise claim `running` and silently resurrect a `paused` one. This
|
|
1224
|
+
* matches `description` and `summary`, where null already means "the
|
|
1225
|
+
* provider did not report this"; status was the one field that invented a
|
|
1226
|
+
* value instead of admitting absence.
|
|
1227
|
+
*/
|
|
1228
|
+
status: BackgroundTaskStatus | null;
|
|
1217
1229
|
description: string | null;
|
|
1218
1230
|
summary: string | null;
|
|
1219
1231
|
parentTaskId: string | null;
|
|
@@ -1373,6 +1385,8 @@ export interface AuthResolveContext {
|
|
|
1373
1385
|
export interface ProviderModel {
|
|
1374
1386
|
id: string;
|
|
1375
1387
|
name: string;
|
|
1388
|
+
/** One-line blurb from the provider's own catalog, when it ships one. */
|
|
1389
|
+
description?: string;
|
|
1376
1390
|
provider?: string;
|
|
1377
1391
|
providerName?: string;
|
|
1378
1392
|
variants?: Array<{
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL cache behind `listModels()`.
|
|
3
|
+
*
|
|
4
|
+
* `ListModelsOptions.cacheTtlMs` was part of the public shape long before
|
|
5
|
+
* anything honored it, so callers could pass a TTL and silently get an
|
|
6
|
+
* uncached spawn every time. Model discovery costs a CLI round trip (or
|
|
7
|
+
* several, when a provider has to probe), which is why the option existed.
|
|
8
|
+
*
|
|
9
|
+
* Entries are keyed on the runtime identity that can change the answer —
|
|
10
|
+
* binary override, cwd, and any env the caller passed. Env is folded in
|
|
11
|
+
* because a different credential can yield a different catalog; only the key
|
|
12
|
+
* names and a hash-free ordering of values are used, and the key never leaves
|
|
13
|
+
* this process.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const store = new Map<string, { expiresAt: number; value: unknown }>();
|
|
17
|
+
|
|
18
|
+
export interface ModelCacheIdentity {
|
|
19
|
+
cwd?: string | undefined;
|
|
20
|
+
env?: Record<string, string | undefined> | undefined;
|
|
21
|
+
config?: { command?: string | undefined } | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function identityKey(provider: string, identity: ModelCacheIdentity): string {
|
|
25
|
+
const env = Object.entries(identity.env ?? {})
|
|
26
|
+
.filter(([, value]) => value !== undefined)
|
|
27
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
28
|
+
return JSON.stringify([provider, identity.cwd ?? "", identity.config?.command ?? "", env]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `load` at most once per TTL window for the given identity.
|
|
33
|
+
*
|
|
34
|
+
* `cacheTtlMs` of 0 (or undefined) forces a fresh load and refreshes the entry,
|
|
35
|
+
* which is how callers implement an explicit "refresh catalog" action.
|
|
36
|
+
* Rejections are never cached: a discovery failure is usually a transient
|
|
37
|
+
* "CLI is mid-upgrade" and should not pin an error for the whole window.
|
|
38
|
+
*/
|
|
39
|
+
export async function withModelCache<T>(
|
|
40
|
+
provider: string,
|
|
41
|
+
identity: ModelCacheIdentity,
|
|
42
|
+
cacheTtlMs: number | undefined,
|
|
43
|
+
load: () => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const key = identityKey(provider, identity);
|
|
46
|
+
const ttl = cacheTtlMs ?? 0;
|
|
47
|
+
if (ttl > 0) {
|
|
48
|
+
const hit = store.get(key);
|
|
49
|
+
if (hit && hit.expiresAt > Date.now()) return hit.value as T;
|
|
50
|
+
}
|
|
51
|
+
const value = await load();
|
|
52
|
+
if (ttl > 0) store.set(key, { expiresAt: Date.now() + ttl, value });
|
|
53
|
+
else store.delete(key);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Drop cached catalogs. Scoped to one provider when named, otherwise all. */
|
|
58
|
+
export function clearModelCache(provider?: string): void {
|
|
59
|
+
if (!provider) {
|
|
60
|
+
store.clear();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const prefix = `["${provider}"`;
|
|
64
|
+
for (const key of [...store.keys()]) {
|
|
65
|
+
if (key.startsWith(prefix)) store.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|