create-quorum-router 0.1.5

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.
@@ -0,0 +1,262 @@
1
+ import {
2
+ discoverInventoryWithModelListing,
3
+ invokableEntries,
4
+ } from "./auth_session.ts";
5
+ import { callEnvFallback } from "./auth_env_fallback.ts";
6
+ import { preparePromptWithContext } from "./context.ts";
7
+ import { readRouterEnv } from "./env.ts";
8
+ import { redact, summarize } from "./redact.ts";
9
+ import { readProviderSelectionRequest } from "./provider_registry.ts";
10
+ import {
11
+ type AgentChatTurn,
12
+ assertAgentChatOptIn,
13
+ assertOptIn,
14
+ type ModelInventoryEntry,
15
+ parseAuthMode,
16
+ type ProviderResult,
17
+ } from "./schema.ts";
18
+ import { selectInvokableCandidates, selectionHonored } from "./best_route.ts";
19
+ import { buildTrace, writeTrace } from "./trace.ts";
20
+ import { callWrapper } from "./wrapper_client.ts";
21
+
22
+ const DEFAULT_MAX_TURNS = 6;
23
+ const MAX_ALLOWED_TURNS = 12;
24
+ const MAX_TRANSCRIPT_CHARS = 12_000;
25
+
26
+ export type AgentChatProgress =
27
+ | { type: "start"; agents: Array<{ provider: string; model: string }> }
28
+ | { type: "turn"; turn: AgentChatTurn }
29
+ | { type: "complete"; turnCount: number };
30
+
31
+ function maxTurns(): number {
32
+ const raw = Deno.env.get("QUORUM_ROUTER_AGENT_CHAT_MAX_TURNS")?.trim();
33
+ if (!raw) return DEFAULT_MAX_TURNS;
34
+ const parsed = Number(raw);
35
+ if (
36
+ !Number.isSafeInteger(parsed) || parsed < 2 || parsed > MAX_ALLOWED_TURNS
37
+ ) {
38
+ throw new Error(
39
+ `QuorumRouter blocked: QUORUM_ROUTER_AGENT_CHAT_MAX_TURNS must be an integer from 2 to ${MAX_ALLOWED_TURNS}`,
40
+ );
41
+ }
42
+ return parsed;
43
+ }
44
+
45
+ function distinctAgents(
46
+ candidates: ModelInventoryEntry[],
47
+ ): ModelInventoryEntry[] {
48
+ const seen = new Set<string>();
49
+ const distinct: ModelInventoryEntry[] = [];
50
+ for (const candidate of candidates) {
51
+ const identity =
52
+ `${candidate.provider.toLowerCase()}/${candidate.model.toLowerCase()}`;
53
+ if (seen.has(identity)) continue;
54
+ seen.add(identity);
55
+ distinct.push(candidate);
56
+ }
57
+ return distinct;
58
+ }
59
+
60
+ function transcriptText(turns: AgentChatTurn[]): string {
61
+ const text = turns.map((turn) =>
62
+ `Round ${turn.round} — ${turn.provider}/${turn.model}${
63
+ turn.reply_to
64
+ ? ` replying to ${turn.reply_to.provider}/${turn.reply_to.model}`
65
+ : ""
66
+ }:\n${turn.content}`
67
+ ).join("\n\n");
68
+ return text.length <= MAX_TRANSCRIPT_CHARS
69
+ ? text
70
+ : text.slice(text.length - MAX_TRANSCRIPT_CHARS);
71
+ }
72
+
73
+ function turnPrompt(args: {
74
+ originalPrompt: string;
75
+ turns: AgentChatTurn[];
76
+ speaker: ModelInventoryEntry;
77
+ peer: ModelInventoryEntry;
78
+ round: number;
79
+ totalRounds: number;
80
+ }): string {
81
+ const prior = transcriptText(args.turns) || "No prior turns.";
82
+ return [
83
+ "You are participating in a bounded cross-model Agent Chat.",
84
+ `You are ${args.speaker.provider}/${args.speaker.model}.`,
85
+ `The other participant is ${args.peer.provider}/${args.peer.model}.`,
86
+ `This is round ${args.round} of ${args.totalRounds}.`,
87
+ "This is a text-only discussion. Do not use tools, inspect files, run commands, or modify the workspace.",
88
+ "Read the prior transcript. Respond directly to the other model's latest argument.",
89
+ "Disagree when warranted, explain why, revise your position when persuaded, and move toward a concrete conclusion.",
90
+ "Do not impersonate the other model. Do not add speaker labels. Keep the response under 900 characters.",
91
+ "",
92
+ `USER TASK:\n${args.originalPrompt}`,
93
+ "",
94
+ `PRIOR TRANSCRIPT:\n${prior}`,
95
+ ].join("\n");
96
+ }
97
+
98
+ async function invoke(
99
+ candidate: ModelInventoryEntry,
100
+ prompt: string,
101
+ ): Promise<ProviderResult> {
102
+ return candidate.source === "env_fallback"
103
+ ? await callEnvFallback(prompt)
104
+ : await callWrapper(candidate, prompt);
105
+ }
106
+
107
+ export async function runAgentChat(
108
+ prompt: string,
109
+ onProgress?: (progress: AgentChatProgress) => void,
110
+ ): Promise<{ tracePath: string; turns: AgentChatTurn[] }> {
111
+ assertAgentChatOptIn();
112
+ assertOptIn();
113
+ const authMode = parseAuthMode(readRouterEnv("QUORUM_ROUTER_AUTH_MODE"));
114
+ const request = readProviderSelectionRequest();
115
+ const inventory = await discoverInventoryWithModelListing(authMode, request);
116
+ const candidates = selectInvokableCandidates(
117
+ invokableEntries(inventory),
118
+ authMode,
119
+ request,
120
+ inventory.entries,
121
+ );
122
+ const candidatePool = distinctAgents(candidates);
123
+ if (candidatePool.length < 2) {
124
+ throw new Error(
125
+ "QuorumRouter blocked: live agent-chat requires at least two distinct invokable provider/model identities. Run `deno task models:list` and authenticate a second provider.",
126
+ );
127
+ }
128
+ if (!candidatePool.every((agent) => selectionHonored(request, agent))) {
129
+ throw new Error(
130
+ "QuorumRouter blocked: agent-chat provider selection was not honored",
131
+ );
132
+ }
133
+
134
+ const prepared = await preparePromptWithContext(prompt);
135
+ const rounds = maxTurns();
136
+ const agents: ModelInventoryEntry[] = [];
137
+ const turns: AgentChatTurn[] = [];
138
+ const results: ProviderResult[] = [];
139
+ const errors: string[] = [];
140
+ let announced = false;
141
+ const announce = () => {
142
+ if (announced || agents.length < 2) return;
143
+ announced = true;
144
+ onProgress?.({
145
+ type: "start",
146
+ agents: agents.map(({ provider, model }) => ({ provider, model })),
147
+ });
148
+ for (const turn of turns) onProgress?.({ type: "turn", turn });
149
+ };
150
+
151
+ for (let index = 0; index < rounds; index += 1) {
152
+ let speaker: ModelInventoryEntry;
153
+ let result: ProviderResult;
154
+ if (agents.length < 2) {
155
+ const unused = candidatePool.filter((candidate) =>
156
+ !agents.some((agent) =>
157
+ agent.provider === candidate.provider &&
158
+ agent.model === candidate.model
159
+ )
160
+ );
161
+ let selected: ModelInventoryEntry | undefined;
162
+ let selectedResult: ProviderResult | undefined;
163
+ for (const candidate of unused) {
164
+ const peer = agents.at(-1) ??
165
+ unused.find((entry) => entry !== candidate) ?? candidate;
166
+ try {
167
+ selectedResult = await invoke(
168
+ candidate,
169
+ turnPrompt({
170
+ originalPrompt: prepared.prompt,
171
+ turns,
172
+ speaker: candidate,
173
+ peer,
174
+ round: index + 1,
175
+ totalRounds: rounds,
176
+ }),
177
+ );
178
+ selected = candidate;
179
+ break;
180
+ } catch (error) {
181
+ errors.push(
182
+ `${candidate.provider}/${candidate.model}: ${
183
+ error instanceof Error ? error.message : String(error)
184
+ }`,
185
+ );
186
+ }
187
+ }
188
+ if (!selected || !selectedResult) {
189
+ throw new Error(
190
+ `QuorumRouter blocked: could not establish two working Agent Chat participants: ${
191
+ errors.join("; ")
192
+ }`,
193
+ );
194
+ }
195
+ agents.push(selected);
196
+ speaker = selected;
197
+ result = selectedResult;
198
+ } else {
199
+ speaker = agents[index % agents.length];
200
+ const peer = agents[(index + 1) % agents.length];
201
+ result = await invoke(
202
+ speaker,
203
+ turnPrompt({
204
+ originalPrompt: prepared.prompt,
205
+ turns,
206
+ speaker,
207
+ peer,
208
+ round: index + 1,
209
+ totalRounds: rounds,
210
+ }),
211
+ );
212
+ }
213
+ if (
214
+ result.provider !== speaker.provider || result.model !== speaker.model
215
+ ) {
216
+ throw new Error(
217
+ `QuorumRouter blocked: agent identity changed during dialogue (expected=${speaker.provider}/${speaker.model} actual=${result.provider}/${result.model})`,
218
+ );
219
+ }
220
+ const content = summarize(redact(result.raw_content), 900);
221
+ const previous = turns.at(-1);
222
+ const turn: AgentChatTurn = {
223
+ round: index + 1,
224
+ provider: result.provider,
225
+ model: result.model,
226
+ reply_to: previous
227
+ ? {
228
+ provider: previous.provider,
229
+ model: previous.model,
230
+ round: previous.round,
231
+ }
232
+ : undefined,
233
+ content,
234
+ };
235
+ const alreadyAnnounced = announced;
236
+ turns.push(turn);
237
+ results.push({
238
+ ...result,
239
+ raw_content: content,
240
+ response_summary: content,
241
+ });
242
+ announce();
243
+ if (alreadyAnnounced) onProgress?.({ type: "turn", turn });
244
+ }
245
+
246
+ const trace = await buildTrace({
247
+ command: "agent-chat",
248
+ mode: "agent_chat",
249
+ authMode,
250
+ prompt: prepared.prompt,
251
+ promptContext: prepared.context,
252
+ results,
253
+ agentChat: true,
254
+ agentChatTurns: turns,
255
+ errors,
256
+ providerSelectionHonored: true,
257
+ fallbackUsed: agents.some((agent) => agent.source === "env_fallback"),
258
+ });
259
+ const tracePath = await writeTrace("agent-chat-trace", trace);
260
+ onProgress?.({ type: "complete", turnCount: turns.length });
261
+ return { tracePath, turns };
262
+ }
@@ -0,0 +1,58 @@
1
+ import { printLoginGuidance } from "./auth_oauth.ts";
2
+ import { readRouterEnv } from "./env.ts";
3
+ import {
4
+ discoverInventoryWithModelListing,
5
+ envFallbackConfigured,
6
+ invokableEntries,
7
+ } from "./auth_session.ts";
8
+ import { printInventoryTable } from "./model_inventory.ts";
9
+ import { parseAuthMode } from "./schema.ts";
10
+
11
+ export async function runAuthStatus(): Promise<void> {
12
+ const authMode = parseAuthMode(readRouterEnv("QUORUM_ROUTER_AUTH_MODE"));
13
+ const inventory = await discoverInventoryWithModelListing(authMode);
14
+ console.log("QuorumRouter auth status");
15
+ console.log(`Auth mode: ${authMode}`);
16
+ console.log("Preferred path: OAuth/session/local-wrapper first");
17
+ console.log("Provider request sent: false");
18
+ console.log("Credential values printed: false");
19
+ console.log(`Env fallback configured: ${envFallbackConfigured()}`);
20
+ printInventoryTable(inventory);
21
+ const usableEntries = invokableEntries(inventory);
22
+ if (usableEntries.length === 0) {
23
+ console.log("Status: missing usable OAuth/session/wrapper provider");
24
+ console.log("Next action: deno task auth:login");
25
+ } else if (authMode === "env") {
26
+ console.log("Status: explicit private env fallback is configured");
27
+ console.log(
28
+ 'Next action: RUN_EXTERNAL_MODEL_DOGFOOD=1 deno task route:once --prompt "Review this README for risky claims."',
29
+ );
30
+ } else {
31
+ console.log(
32
+ "Status: at least one OAuth/session/wrapper provider appears usable",
33
+ );
34
+ console.log(
35
+ 'Next action: RUN_EXTERNAL_MODEL_DOGFOOD=1 deno task route:once --prompt "Review this README for risky claims."',
36
+ );
37
+ }
38
+ }
39
+
40
+ export function runAuthLogin(): never {
41
+ return printLoginGuidance();
42
+ }
43
+
44
+ export async function runAuthLogout(): Promise<void> {
45
+ try {
46
+ await Deno.remove(".quorum-router", { recursive: true });
47
+ } catch (error) {
48
+ if (!(error instanceof Deno.errors.NotFound)) throw error;
49
+ }
50
+ console.log("QuorumRouter auth logout");
51
+ console.log(
52
+ "Removed generated local session metadata directory if present: .quorum-router/",
53
+ );
54
+ console.log(
55
+ "Provider CLI/browser sessions are managed by each provider CLI and were not modified.",
56
+ );
57
+ console.log("Credential values printed: false");
58
+ }
@@ -0,0 +1,78 @@
1
+ import { redact, summarize } from "./redact.ts";
2
+ import type { ProviderResult } from "./schema.ts";
3
+ import { readRouterEnv } from "./env.ts";
4
+
5
+ function requiredEnv(name: string): string {
6
+ const value = Deno.env.get(name)?.trim();
7
+ if (!value) {
8
+ throw new Error(`QuorumRouter env fallback blocked: missing ${name}`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function chatCompletionsUrl(baseUrl: string): string {
14
+ let url: URL;
15
+ try {
16
+ url = new URL(baseUrl);
17
+ } catch {
18
+ throw new Error(
19
+ "QuorumRouter env fallback blocked: QUORUM_ROUTER_PROVIDER_BASE_URL must be a valid URL",
20
+ );
21
+ }
22
+ const normalizedPath = url.pathname.replace(/\/+$/, "");
23
+ if (normalizedPath.endsWith("/chat/completions")) {
24
+ url.pathname = normalizedPath;
25
+ return url.toString();
26
+ }
27
+ url.pathname = `${normalizedPath}/chat/completions`;
28
+ return url.toString();
29
+ }
30
+
31
+ export async function callEnvFallback(prompt: string): Promise<ProviderResult> {
32
+ const baseUrl = requiredEnv("QUORUM_ROUTER_PROVIDER_BASE_URL");
33
+ const credential = requiredEnv("QUORUM_ROUTER_PROVIDER_API_KEY");
34
+ const model = requiredEnv("QUORUM_ROUTER_PROVIDER_MODEL");
35
+ const provider = readRouterEnv("QUORUM_ROUTER_PROVIDER_LABEL")?.trim() ||
36
+ "OpenAI-compatible env fallback";
37
+ const response = await fetch(chatCompletionsUrl(baseUrl), {
38
+ method: "POST",
39
+ signal: AbortSignal.timeout(120_000),
40
+ headers: {
41
+ authorization: `Bearer ${credential}`,
42
+ "content-type": "application/json",
43
+ },
44
+ body: JSON.stringify({
45
+ model,
46
+ messages: [{ role: "user", content: prompt }],
47
+ temperature: 0,
48
+ }),
49
+ });
50
+ if (!response.ok) {
51
+ const body = redact(await response.text().catch(() => ""), [credential]);
52
+ throw new Error(
53
+ `QuorumRouter env fallback blocked: env fallback HTTP ${response.status}${
54
+ body ? `: ${body.slice(0, 300)}` : ""
55
+ }`,
56
+ );
57
+ }
58
+ const json = await response.json() as {
59
+ choices?: Array<{ message?: { content?: unknown }; text?: unknown }>;
60
+ };
61
+ const content = json.choices?.[0]?.message?.content ??
62
+ json.choices?.[0]?.text;
63
+ if (typeof content !== "string" || !content.trim()) {
64
+ throw new Error(
65
+ "QuorumRouter env fallback blocked: env fallback returned unexpected response shape",
66
+ );
67
+ }
68
+ return {
69
+ provider,
70
+ model,
71
+ model_id: `env/${model}`,
72
+ source: "env_fallback",
73
+ response_received: true,
74
+ schema_valid: true,
75
+ response_summary: summarize(content),
76
+ raw_content: content,
77
+ };
78
+ }
@@ -0,0 +1,19 @@
1
+ export function browserLoginSupported(): boolean {
2
+ return false;
3
+ }
4
+
5
+ export function printLoginGuidance(): never {
6
+ console.error(
7
+ "OAuth/session login is not configured for this generated scaffold yet.",
8
+ );
9
+ console.error("Use an installed provider CLI login, then rerun:");
10
+ console.error(" deno task intake");
11
+ console.error("");
12
+ console.error(
13
+ "Generic env fallback exists, but it is private/manual and not the preferred path.",
14
+ );
15
+ console.error(
16
+ "Never paste tokens into chat/logs and never commit .env or .quorum-router/.",
17
+ );
18
+ Deno.exit(1);
19
+ }
@@ -0,0 +1,271 @@
1
+ import {
2
+ envFallbackEntry,
3
+ LOCAL_PROVIDER_SPECS,
4
+ providerSelectionMatches,
5
+ type ProviderSelectionRequest,
6
+ type ProviderSpec,
7
+ } from "./provider_registry.ts";
8
+ import { redact, summarize } from "./redact.ts";
9
+ import { readRouterEnv } from "./env.ts";
10
+ import {
11
+ type AuthMode,
12
+ type ModelInventory,
13
+ type ModelInventoryEntry,
14
+ parseAuthMode,
15
+ } from "./schema.ts";
16
+
17
+ export function commandExists(command: string): boolean {
18
+ const path = Deno.env.get("PATH") ?? "";
19
+ const sep = Deno.build.os === "windows" ? ";" : ":";
20
+ const exts = Deno.build.os === "windows"
21
+ ? (Deno.env.get("PATHEXT") ?? ".EXE;.CMD;.BAT").split(";")
22
+ : [""];
23
+ for (const dir of path.split(sep)) {
24
+ if (!dir) continue;
25
+ for (const ext of exts) {
26
+ try {
27
+ const stat = Deno.statSync(`${dir}/${command}${ext}`);
28
+ if (!stat.isFile) continue;
29
+ if (Deno.build.os === "windows" || ((stat.mode ?? 0) & 0o111) !== 0) {
30
+ return true;
31
+ }
32
+ } catch { /* keep scanning */ }
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+
38
+ export function envFallbackConfigured(): boolean {
39
+ return Boolean(
40
+ readRouterEnv("QUORUM_ROUTER_PROVIDER_BASE_URL")?.trim() &&
41
+ readRouterEnv("QUORUM_ROUTER_PROVIDER_API_KEY")?.trim() &&
42
+ readRouterEnv("QUORUM_ROUTER_PROVIDER_MODEL")?.trim(),
43
+ );
44
+ }
45
+
46
+ function includeForMode(entryAuth: string, mode: AuthMode): boolean {
47
+ if (mode === "auto") return entryAuth !== "env";
48
+ if (mode === "wrapper") {
49
+ return entryAuth === "oauth" || entryAuth === "session";
50
+ }
51
+ if (mode === "oauth") return entryAuth === "oauth";
52
+ if (mode === "session") return entryAuth === "session";
53
+ return entryAuth === "env";
54
+ }
55
+
56
+ function specKey(spec: ProviderSpec): string {
57
+ return `${spec.provider}\u0000${spec.model_id}`;
58
+ }
59
+
60
+ function specForEntry(entry: ModelInventoryEntry): ProviderSpec | undefined {
61
+ return LOCAL_PROVIDER_SPECS.find((spec) =>
62
+ specKey(spec) === `${entry.provider}\u0000${entry.model_id}`
63
+ );
64
+ }
65
+
66
+ export function discoverInventory(
67
+ mode = parseAuthMode(readRouterEnv("QUORUM_ROUTER_AUTH_MODE")),
68
+ ): ModelInventory {
69
+ const entries: ModelInventoryEntry[] = [];
70
+ for (const spec of LOCAL_PROVIDER_SPECS) {
71
+ if (!includeForMode(spec.auth_mode, mode)) continue;
72
+ const exists = spec.command ? commandExists(spec.command) : false;
73
+ entries.push({
74
+ provider: spec.provider,
75
+ auth_mode: spec.auth_mode,
76
+ model: spec.model,
77
+ model_id: spec.model_id,
78
+ source: spec.source,
79
+ available: exists,
80
+ blocked_reason: exists
81
+ ? undefined
82
+ : `missing local command: ${spec.command}`,
83
+ can_list_models: false,
84
+ list_blocked_reason: spec.list_blocked_reason ??
85
+ (spec.list_models_args
86
+ ? "model listing not run in sync inventory path"
87
+ : "no safe non-interactive model list command configured"),
88
+ can_invoke: exists,
89
+ command: spec.command,
90
+ args_template: spec.args_template,
91
+ notes: exists
92
+ ? [
93
+ ...spec.notes,
94
+ "Command exists; actual auth is verified only during explicit opt-in invocation.",
95
+ ]
96
+ : spec.notes,
97
+ });
98
+ }
99
+
100
+ const fallback = envFallbackEntry(envFallbackConfigured());
101
+ if (mode === "env") {
102
+ entries.push(fallback);
103
+ } else if (mode === "auto") {
104
+ // Report but do not silently use env fallback in auto mode.
105
+ entries.push({
106
+ ...fallback,
107
+ available: false,
108
+ can_invoke: false,
109
+ blocked_reason: fallback.available
110
+ ? "env fallback configured but not used unless QUORUM_ROUTER_AUTH_MODE=env"
111
+ : fallback.blocked_reason,
112
+ });
113
+ }
114
+
115
+ return {
116
+ generated_at: new Date().toISOString(),
117
+ auth_mode: mode,
118
+ entries,
119
+ available_count: entries.filter((entry) => entry.available).length,
120
+ blocked_count: entries.filter((entry) => !entry.available).length,
121
+ env_fallback_configured: envFallbackConfigured(),
122
+ env_fallback_used: mode === "env" && envFallbackConfigured(),
123
+ };
124
+ }
125
+
126
+ function listCommandEnv(): Record<string, string> {
127
+ const env: Record<string, string> = {};
128
+ for (
129
+ const name of [
130
+ "PATH",
131
+ "HOME",
132
+ "TMPDIR",
133
+ "TMP",
134
+ "TEMP",
135
+ "LANG",
136
+ "LC_ALL",
137
+ "TERM",
138
+ ]
139
+ ) {
140
+ const value = Deno.env.get(name);
141
+ if (value) env[name] = value;
142
+ }
143
+ return env;
144
+ }
145
+
146
+ async function outputWithTimeout(
147
+ command: string,
148
+ args: string[],
149
+ timeoutMs: number,
150
+ ): Promise<Deno.CommandOutput> {
151
+ const child = new Deno.Command(command, {
152
+ args,
153
+ clearEnv: true,
154
+ env: listCommandEnv(),
155
+ stdin: "null",
156
+ stdout: "piped",
157
+ stderr: "piped",
158
+ }).spawn();
159
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
160
+ let killId: ReturnType<typeof setTimeout> | undefined;
161
+ try {
162
+ return await Promise.race([
163
+ child.output(),
164
+ new Promise<Deno.CommandOutput>((_, reject) => {
165
+ timeoutId = setTimeout(() => {
166
+ try {
167
+ child.kill("SIGTERM");
168
+ } catch { /* best effort */ }
169
+ killId = setTimeout(() => {
170
+ try {
171
+ child.kill("SIGKILL");
172
+ } catch { /* best effort */ }
173
+ reject(new Error("model listing timed out"));
174
+ }, 1_000);
175
+ }, timeoutMs);
176
+ }),
177
+ ]);
178
+ } finally {
179
+ if (timeoutId) clearTimeout(timeoutId);
180
+ if (killId) clearTimeout(killId);
181
+ }
182
+ }
183
+
184
+ export function parseGrokModelList(output: string): string[] {
185
+ const models = new Set<string>();
186
+ for (const rawLine of output.split(/\r?\n/)) {
187
+ const line = rawLine.trim();
188
+ const match = line.match(/^(?:[*-])\s+([^\s(]+)(?:\s+\(default\))?$/);
189
+ if (match) models.add(match[1]);
190
+ }
191
+ return [...models].sort();
192
+ }
193
+
194
+ async function enrichModelListing(
195
+ entry: ModelInventoryEntry,
196
+ ): Promise<ModelInventoryEntry> {
197
+ const spec = specForEntry(entry);
198
+ if (!entry.available || !entry.command || !spec?.list_models_args) {
199
+ return entry;
200
+ }
201
+ try {
202
+ const output = await outputWithTimeout(
203
+ entry.command,
204
+ spec.list_models_args,
205
+ 10_000,
206
+ );
207
+ const stdout = new TextDecoder().decode(output.stdout);
208
+ const stderr = new TextDecoder().decode(output.stderr);
209
+ if (output.code !== 0) {
210
+ return {
211
+ ...entry,
212
+ can_list_models: false,
213
+ list_blocked_reason: summarize(redact(stderr || stdout), 300),
214
+ };
215
+ }
216
+ const listedModels = entry.provider === "xAI"
217
+ ? parseGrokModelList(stdout)
218
+ : [];
219
+ return {
220
+ ...entry,
221
+ can_list_models: listedModels.length > 0,
222
+ listed_models: listedModels,
223
+ list_blocked_reason: listedModels.length > 0
224
+ ? undefined
225
+ : "model list command succeeded but no model ids were parsed",
226
+ notes: listedModels.length > 0
227
+ ? [
228
+ ...entry.notes,
229
+ `Listed ${listedModels.length} model(s) via safe list-only command.`,
230
+ ]
231
+ : entry.notes,
232
+ };
233
+ } catch (error) {
234
+ return {
235
+ ...entry,
236
+ can_list_models: false,
237
+ list_blocked_reason: summarize(
238
+ redact(error instanceof Error ? error.message : String(error)),
239
+ 300,
240
+ ),
241
+ };
242
+ }
243
+ }
244
+
245
+ export async function discoverInventoryWithModelListing(
246
+ mode = parseAuthMode(readRouterEnv("QUORUM_ROUTER_AUTH_MODE")),
247
+ request: ProviderSelectionRequest = {},
248
+ ): Promise<ModelInventory> {
249
+ const inventory = discoverInventory(mode);
250
+ const entries = await Promise.all(inventory.entries.map((entry) => {
251
+ if (
252
+ request.providerLabel &&
253
+ !providerSelectionMatches(entry, request.providerLabel)
254
+ ) {
255
+ return Promise.resolve(entry);
256
+ }
257
+ return enrichModelListing(entry);
258
+ }));
259
+ return {
260
+ ...inventory,
261
+ entries,
262
+ };
263
+ }
264
+
265
+ export function invokableEntries(
266
+ inventory: ModelInventory,
267
+ ): ModelInventoryEntry[] {
268
+ return inventory.entries.filter((entry) =>
269
+ entry.available && entry.can_invoke
270
+ );
271
+ }