@kevin5251984/guild 0.2.18 → 0.2.19

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/src/mention.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  /** @channel / @quest / @here / @all still mean the whole quest. */
2
2
  const BROADCAST = /(^|\s)@(channel|quest|here|all)\b/i;
3
3
  const HANDLE = /@([A-Za-z0-9_-]+)/g;
4
- /** Consecutive @handles at the start of a line. */
5
- const LINE_LEAD = /^[ \t]*((?:@[A-Za-z0-9_-]+[\s,、]*)+)/;
4
+ /** Consecutive @handles at the start of a line (optional backticks). */
5
+ const LINE_LEAD = /^[ \t]*((?:`?@[A-Za-z0-9_-]+`?[\s,、]*)+)/;
6
+ /** `1. @design` / `- @infra`. */
7
+ const LIST_MARK = /^[ \t]*(?:\d+[.)、]\s*|[-*•]\s+)/;
8
+ /** Assignee right after the marker, or after 通過後/最後/再叫… — not the first @ anywhere on the line. */
9
+ const LIST_ASSIGN =
10
+ /^(?:`?@([A-Za-z0-9_-]+)`?|(?:通過後|最後|再叫|交給|交棒(?:給)?|指派|(?:call|ask)\s+)\s*`?@([A-Za-z0-9_-]+)`?)/i;
6
11
 
7
12
  /** Drop fenced / inline code so `@pm` in a snippet does not dispatch. */
8
13
  export function stripMentionNoise(text: string): string {
@@ -32,13 +37,24 @@ export type MentionBlock = {
32
37
  start: number;
33
38
  end: number;
34
39
  handles: string[];
40
+ kind: "lead" | "list";
35
41
  };
36
42
 
37
- /** Line-start @handle groups. Prose `@pm` in the middle of a line is not a block. */
43
+ function knownHandlesIn(chunk: string, known: Set<string>): string[] {
44
+ const names: string[] = [];
45
+ for (const row of chunk.matchAll(HANDLE)) {
46
+ const key = row[1].toLowerCase();
47
+ if (!known.has(key) || names.includes(key)) continue;
48
+ names.push(key);
49
+ }
50
+ return names;
51
+ }
52
+
53
+ /** Line-start @handle groups, plus markdown list items that name a seat. */
38
54
  export function lineStartMentions(text: string, handles: string[]): MentionBlock[] {
39
55
  const raw = String(text ?? "");
40
56
  const known = new Set(handles.map((handle) => handle.toLowerCase()));
41
- const starts: { start: number; handles: string[] }[] = [];
57
+ const starts: { start: number; handles: string[]; kind: "lead" | "list" }[] = [];
42
58
  let fence = false;
43
59
  let offset = 0;
44
60
  for (const part of raw.split(/(\r?\n)/)) {
@@ -53,15 +69,22 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
53
69
  continue;
54
70
  }
55
71
  if (!fence) {
72
+ const listed = LIST_MARK.test(part);
56
73
  const lead = part.match(LINE_LEAD);
57
- if (lead) {
58
- const names: string[] = [];
59
- for (const row of lead[1].matchAll(HANDLE)) {
60
- const key = row[1].toLowerCase();
61
- if (!known.has(key) || names.includes(key)) continue;
62
- names.push(key);
63
- }
64
- if (names.length) starts.push({ start: offset, handles: names });
74
+ let names: string[] = [];
75
+ if (lead) names = knownHandlesIn(lead[1], known);
76
+ if (!names.length && listed) {
77
+ const rest = part.replace(LIST_MARK, "");
78
+ const hit = rest.match(LIST_ASSIGN);
79
+ const key = (hit?.[1] || hit?.[2] || "").toLowerCase();
80
+ if (key && known.has(key)) names = [key];
81
+ }
82
+ if (names.length) {
83
+ starts.push({
84
+ start: offset,
85
+ handles: names,
86
+ kind: listed ? "list" : "lead",
87
+ });
65
88
  }
66
89
  }
67
90
  offset += part.length;
@@ -70,6 +93,7 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
70
93
  start: row.start,
71
94
  end: i + 1 < starts.length ? starts[i + 1].start : raw.length,
72
95
  handles: row.handles,
96
+ kind: row.kind,
73
97
  }));
74
98
  }
75
99
 
@@ -104,6 +128,87 @@ export function summonedHandles(text: string, handles: string[]): string[] {
104
128
  return [];
105
129
  }
106
130
 
131
+ /** Fenced samples only. Keep inline `code` so 「再叫 `@infra`」 still parses. */
132
+ function stripFences(text: string): string {
133
+ return String(text ?? "").replace(/```[\s\S]*?```/g, " ");
134
+ }
135
+
136
+ /**
137
+ * Bot replies: line-start specs, else first prose @handle, plus assignment verbs
138
+ * even when the handle is wrapped in backticks (models quote @infra a lot).
139
+ * Fixture chatter like `(@pm / @rd)` has no verb, so it does not hop.
140
+ */
141
+ const HANDOFF_ASK =
142
+ /(?:(?:再叫|交給|交棒(?:給)?|指派)\s*|(?:call|ask|ping|notify|handoff(?:\s+to)?)\s+)`?@([A-Za-z0-9_-]+)`?/gi;
143
+
144
+ export function handoffHandles(text: string, handles: string[]): string[] {
145
+ const known = new Set(handles.map((handle) => handle.toLowerCase()));
146
+ const out = summonedHandles(text, handles);
147
+ const ask = new RegExp(HANDOFF_ASK.source, "gi");
148
+ for (const row of stripFences(text).matchAll(ask)) {
149
+ const key = row[1].toLowerCase();
150
+ if (!known.has(key) || out.includes(key)) continue;
151
+ out.push(key);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ export type MentionBot = { id: string; handle: string };
157
+
158
+ export function sanitizeMentionIds(
159
+ raw: unknown,
160
+ bots: MentionBot[],
161
+ ): string[] {
162
+ if (!Array.isArray(raw)) return [];
163
+ const known = new Set(bots.map((bot) => bot.id));
164
+ const out: string[] = [];
165
+ for (const item of raw) {
166
+ if (typeof item !== "string") continue;
167
+ const id = item.trim();
168
+ if (!id || !known.has(id) || out.includes(id)) continue;
169
+ out.push(id);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ export function parseMentionIds(
175
+ text: string,
176
+ bots: MentionBot[],
177
+ kind: "user" | "bot",
178
+ ): string[] {
179
+ if (isBroadcastMention(text)) return [];
180
+ const handles = bots.map((bot) => bot.handle);
181
+ const names =
182
+ kind === "bot" ? handoffHandles(text, handles) : summonedHandles(text, handles);
183
+ const out: string[] = [];
184
+ for (const name of names) {
185
+ const bot = bots.find(
186
+ (row) => row.handle.toLowerCase() === name.toLowerCase(),
187
+ );
188
+ if (!bot || out.includes(bot.id)) continue;
189
+ out.push(bot.id);
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Prefer the stored mention list. Older rows without the field still parse the body.
196
+ * An empty stored list means nobody.
197
+ */
198
+ export function messageMentionIds(
199
+ message: { author: string; body: string; mentions?: string[] },
200
+ bots: MentionBot[],
201
+ ): string[] {
202
+ const ids = Array.isArray(message.mentions)
203
+ ? sanitizeMentionIds(message.mentions, bots)
204
+ : parseMentionIds(
205
+ message.body,
206
+ bots,
207
+ message.author === "you" ? "user" : "bot",
208
+ );
209
+ return ids.filter((id) => id !== message.author);
210
+ }
211
+
107
212
  /**
108
213
  * Spec for one seat: shared preamble plus that @handle's line-start block.
109
214
  * Full text when the handle was only summoned in prose.
@@ -119,6 +224,8 @@ export function assignmentFor(
119
224
  if (!key || !blocks.length) return raw;
120
225
  const mine = blocks.filter((block) => block.handles.includes(key));
121
226
  if (!mine.length) return raw;
227
+ // Numbered plans share constraints ("don't ship until PM signs off"). Slice only classic specs.
228
+ if (!blocks.some((block) => block.kind === "lead")) return raw;
122
229
  const preamble = raw.slice(0, blocks[0].start).trim();
123
230
  const chunks = mine.map((block) => raw.slice(block.start, block.end).trim());
124
231
  return [preamble, ...chunks].filter(Boolean).join("\n\n");
package/src/oauth.ts CHANGED
@@ -1057,6 +1057,109 @@ function thinkingText(message: AssistantMessage | undefined): string {
1057
1057
  .trim();
1058
1058
  }
1059
1059
 
1060
+ /**
1061
+ * Codex `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. No tokens on the stream — not a
1062
+ * wall clock on the whole turn. Do not pass this as Pi `timeoutMs` for xAI:
1063
+ * OpenAI-completions maps that to the SDK request timeout and kills thinking.
1064
+ */
1065
+ export const STREAM_IDLE_TIMEOUT_MS = 300_000;
1066
+
1067
+ export function isTransientLlmError(err: unknown): boolean {
1068
+ if (err instanceof StreamIdleError) return false;
1069
+ const text = err instanceof Error ? `${err.name} ${err.message}` : String(err);
1070
+ return /econnreset|econnrefused|etimedout|enotfound|eai_again|fetch failed|socket|network|empty reply|429|502|503|504|5\d\d/i.test(
1071
+ text,
1072
+ );
1073
+ }
1074
+
1075
+ export async function withTransientRetries<T>(
1076
+ run: () => Promise<T>,
1077
+ opts: {
1078
+ signal?: AbortSignal;
1079
+ attempts?: number;
1080
+ onRetry?: (attempt: number, err: unknown) => void;
1081
+ } = {},
1082
+ ): Promise<T> {
1083
+ const attempts = opts.attempts ?? 3;
1084
+ let last: unknown;
1085
+ for (let i = 0; i < attempts; i++) {
1086
+ if (opts.signal?.aborted) {
1087
+ throw userAborted(opts.signal);
1088
+ }
1089
+ try {
1090
+ return await run();
1091
+ } catch (err) {
1092
+ last = err;
1093
+ if (err instanceof Error && err.name === "AbortError") throw err;
1094
+ if (opts.signal?.aborted) throw err;
1095
+ if (!isTransientLlmError(err) || i === attempts - 1) throw err;
1096
+ opts.onRetry?.(i + 1, err);
1097
+ await new Promise((resolve) => setTimeout(resolve, 400 * (i + 1)));
1098
+ }
1099
+ }
1100
+ throw last instanceof Error ? last : new Error(String(last));
1101
+ }
1102
+
1103
+ export class StreamIdleError extends Error {
1104
+ readonly idleMs: number;
1105
+ constructor(idleMs: number) {
1106
+ super(
1107
+ `stream idle: no tokens for ${Math.round(idleMs / 1000)}s (Codex-style; not a turn wall clock). Resend or switch models.`,
1108
+ );
1109
+ this.name = "StreamIdleError";
1110
+ this.idleMs = idleMs;
1111
+ }
1112
+ }
1113
+
1114
+ export function startStreamIdle(
1115
+ idleMs: number,
1116
+ parent?: AbortSignal,
1117
+ ): {
1118
+ signal: AbortSignal;
1119
+ bump: () => void;
1120
+ dispose: () => void;
1121
+ timedOut: () => boolean;
1122
+ } {
1123
+ const ctrl = new AbortController();
1124
+ let timer: ReturnType<typeof setTimeout> | undefined;
1125
+ let timedOut = false;
1126
+ const fire = () => {
1127
+ if (timedOut || ctrl.signal.aborted) return;
1128
+ timedOut = true;
1129
+ ctrl.abort();
1130
+ };
1131
+ const bump = () => {
1132
+ if (timedOut || parent?.aborted) return;
1133
+ if (timer) clearTimeout(timer);
1134
+ timer = setTimeout(fire, idleMs);
1135
+ };
1136
+ const onParent = () => {
1137
+ if (timer) clearTimeout(timer);
1138
+ ctrl.abort();
1139
+ };
1140
+ if (parent?.aborted) {
1141
+ ctrl.abort();
1142
+ } else {
1143
+ parent?.addEventListener("abort", onParent, { once: true });
1144
+ }
1145
+ bump();
1146
+ return {
1147
+ signal: ctrl.signal,
1148
+ bump,
1149
+ dispose: () => {
1150
+ if (timer) clearTimeout(timer);
1151
+ parent?.removeEventListener("abort", onParent);
1152
+ },
1153
+ timedOut: () => timedOut,
1154
+ };
1155
+ }
1156
+
1157
+ function userAborted(signal?: AbortSignal): Error {
1158
+ const err = new Error("aborted");
1159
+ err.name = "AbortError";
1160
+ return err;
1161
+ }
1162
+
1060
1163
  /** Pi streamSimple: Think chips only from thinking_delta, not a planted placeholder. */
1061
1164
  async function streamOAuthMessage(
1062
1165
  models: MutableModels,
@@ -1066,7 +1169,11 @@ async function streamOAuthMessage(
1066
1169
  toolCtx: ToolContext,
1067
1170
  traces: ToolTrace[],
1068
1171
  ): Promise<AssistantMessage> {
1069
- const stream = models.streamSimple(model, context, options);
1172
+ const idle = startStreamIdle(STREAM_IDLE_TIMEOUT_MS, options?.signal);
1173
+ const stream = models.streamSimple(model, context, {
1174
+ ...options,
1175
+ signal: idle.signal,
1176
+ });
1070
1177
  let lastEmit = 0;
1071
1178
  const flush = (partial: AssistantMessage | undefined, force: boolean) => {
1072
1179
  const think = thinkingText(partial);
@@ -1076,19 +1183,40 @@ async function streamOAuthMessage(
1076
1183
  lastEmit = now;
1077
1184
  emitProgress(toolCtx, traces, think);
1078
1185
  };
1079
- for await (const event of stream) {
1080
- if (event.type === "thinking_start" || event.type === "thinking_delta") {
1081
- flush(event.partial, event.type === "thinking_start");
1082
- } else if (event.type === "thinking_end") {
1083
- flush(event.partial, true);
1084
- } else if (event.type === "done") {
1085
- flush(event.message, true);
1086
- return event.message;
1087
- } else if (event.type === "error") {
1088
- return event.error;
1186
+ const failIfIdle = (): never => {
1187
+ if (options?.signal?.aborted) throw userAborted(options.signal);
1188
+ throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
1189
+ };
1190
+ try {
1191
+ for await (const event of stream) {
1192
+ idle.bump();
1193
+ if (event.type === "thinking_start" || event.type === "thinking_delta") {
1194
+ flush(event.partial, event.type === "thinking_start");
1195
+ } else if (event.type === "thinking_end") {
1196
+ flush(event.partial, true);
1197
+ } else if (event.type === "done") {
1198
+ flush(event.message, true);
1199
+ return event.message;
1200
+ } else if (event.type === "error") {
1201
+ if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
1202
+ return event.error;
1203
+ }
1204
+ }
1205
+ if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
1206
+ return stream.result();
1207
+ } catch (err) {
1208
+ if (err instanceof StreamIdleError) throw err;
1209
+ if (err instanceof Error && err.name === "AbortError") {
1210
+ if (options?.signal?.aborted) throw err;
1211
+ if (idle.timedOut()) throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
1212
+ }
1213
+ if (idle.timedOut() && !(options?.signal?.aborted)) {
1214
+ throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
1089
1215
  }
1216
+ throw err;
1217
+ } finally {
1218
+ idle.dispose();
1090
1219
  }
1091
- return stream.result();
1092
1220
  }
1093
1221
 
1094
1222
  function abortWhen(signal?: AbortSignal): Promise<never> {
@@ -1252,21 +1380,70 @@ export async function completeOAuth(input: {
1252
1380
  if (fitted.length < transcript.length) {
1253
1381
  transcript.splice(0, transcript.length, ...fitted);
1254
1382
  }
1255
- const result = await Promise.race([
1256
- streamOAuthMessage(
1257
- models,
1258
- model,
1383
+ let result: AssistantMessage;
1384
+ try {
1385
+ result = await withTransientRetries(
1386
+ async () => {
1387
+ const got = await Promise.race([
1388
+ streamOAuthMessage(
1389
+ models,
1390
+ model,
1391
+ {
1392
+ systemPrompt: input.system,
1393
+ messages: transcript,
1394
+ ...(useTools ? { tools } : {}),
1395
+ },
1396
+ options,
1397
+ toolCtx,
1398
+ traces,
1399
+ ),
1400
+ abortWhen(options.signal),
1401
+ ]);
1402
+ if (got.stopReason === "aborted") {
1403
+ const err = new Error("aborted");
1404
+ err.name = "AbortError";
1405
+ throw err;
1406
+ }
1407
+ if (got.stopReason === "error") {
1408
+ throw new Error(
1409
+ formatOAuthError(sub.id, got.errorMessage) ||
1410
+ `${sub.id} request failed`,
1411
+ );
1412
+ }
1413
+ const text = contentText(got.content).trim();
1414
+ const toolCalls =
1415
+ got.stopReason === "toolUse"
1416
+ ? got.content.filter(
1417
+ (part): part is Extract<typeof part, { type: "toolCall" }> =>
1418
+ part.type === "toolCall",
1419
+ )
1420
+ : [];
1421
+ if (!toolCalls.length && !text && traces.length === 0) {
1422
+ throw new Error(`${sub.id} returned an empty reply`);
1423
+ }
1424
+ return got;
1425
+ },
1259
1426
  {
1260
- systemPrompt: input.system,
1261
- messages: transcript,
1262
- ...(useTools ? { tools } : {}),
1427
+ signal: options.signal,
1428
+ onRetry: () => {
1429
+ emitProgress(
1430
+ toolCtx,
1431
+ traces,
1432
+ thinkingChunks.join("\n\n") || "連線中斷,重試中…",
1433
+ );
1434
+ },
1263
1435
  },
1264
- options,
1265
- toolCtx,
1266
- traces,
1267
- ),
1268
- abortWhen(options.signal),
1269
- ]);
1436
+ );
1437
+ } catch (err) {
1438
+ if (err instanceof StreamIdleError) {
1439
+ return {
1440
+ calls: [],
1441
+ text: err.message,
1442
+ thinking: thinkingChunks.join("\n\n"),
1443
+ };
1444
+ }
1445
+ throw err;
1446
+ }
1270
1447
  if (result.stopReason === "aborted" || toolCtx.signal?.aborted || options.signal?.aborted) {
1271
1448
  const err = new Error("aborted");
1272
1449
  err.name = "AbortError";
@@ -0,0 +1,247 @@
1
+ import type { ModelEntry, ProviderEntry } from "@guild/protocol";
2
+
3
+ /** Built-in alias `free` → this provider, matching Hermes Agent (2026-08). */
4
+ export const OPENCODE_FREE_PROVIDER_ID = "opencode-free";
5
+ export const OPENCODE_FREE_BASE_URL = "https://opencode.ai/zen/v1";
6
+
7
+ const ALIASES = new Set(["opencode-free", "free", "opencode_free"]);
8
+
9
+ /**
10
+ * Offline floor — Hermes Agent's curated catalog.
11
+ * Live GET /zen/v1/models is the source of truth when reachable.
12
+ * Known-delisted slugs must not stay here (they 401 keyless).
13
+ */
14
+ export const OPENCODE_FREE_FLOOR = [
15
+ "laguna-s-2.1-free",
16
+ "mimo-v2.5-free",
17
+ "nemotron-3.5-lightning-free",
18
+ "nemotron-3-ultra-free",
19
+ "muse-spark-1.2-contributor-free",
20
+ "ling-3.0-flash-fin-free",
21
+ "deepseek-v4-flash-free",
22
+ ] as const;
23
+
24
+ /** Default pick — user-facing Guild default, routed to `/v1/responses`. */
25
+ export const OPENCODE_FREE_DEFAULT_MODEL = "muse-spark-1.2-contributor-free";
26
+
27
+ /**
28
+ * Hermes `opencode_model_api_mode`: Muse Spark on Zen/Go is Responses-only.
29
+ * `/v1/chat/completions` 500s; `/v1/responses` completes.
30
+ */
31
+ export function usesZenResponses(model: string): boolean {
32
+ return String(model || "")
33
+ .trim()
34
+ .toLowerCase()
35
+ .startsWith("muse-spark");
36
+ }
37
+
38
+ /** Free slugs that do not end in `-free` (OpenCode's rotating stealth slot). */
39
+ const EXTRA_SLUGS = new Set(["big-pickle"]);
40
+
41
+ /** `-free` suffix but KEYED (Go subscription), not anonymous. */
42
+ const KEYED_FREE_SUFFIX = new Set(["ox-alpha-free"]);
43
+
44
+ export function isKeylessProvider(id: string): boolean {
45
+ return ALIASES.has(String(id || "").trim().toLowerCase());
46
+ }
47
+
48
+ export function prettyOpenCodeFreeName(id: string): string {
49
+ const bare = String(id || "").trim().split("/").pop() || String(id || "");
50
+ const slug = bare.replace(/-/g, " ").replace(/\s+/g, " ").trim();
51
+ if (!slug) return id;
52
+ return slug.replace(/\b([a-z])/g, (ch) => ch.toUpperCase());
53
+ }
54
+
55
+ export function openCodeFreeModels(ids: readonly string[] = OPENCODE_FREE_FLOOR): ModelEntry[] {
56
+ return ids.map((id) => ({ id, name: prettyOpenCodeFreeName(id) }));
57
+ }
58
+
59
+ export function openCodeFreeProvider(): ProviderEntry {
60
+ return {
61
+ name: "OpenCode Free",
62
+ baseUrl: OPENCODE_FREE_BASE_URL,
63
+ api: "openai-completions",
64
+ apiKey: "",
65
+ models: openCodeFreeModels(),
66
+ };
67
+ }
68
+
69
+ export function filterOpenCodeFreeIds(ids: string[]): string[] {
70
+ const out: string[] = [];
71
+ const seen = new Set<string>();
72
+ for (const raw of ids) {
73
+ const id = String(raw || "").trim();
74
+ if (!id) continue;
75
+ const bare = (id.split("/").pop() || id).toLowerCase();
76
+ const ok =
77
+ (bare.endsWith("-free") && !KEYED_FREE_SUFFIX.has(bare)) ||
78
+ EXTRA_SLUGS.has(bare);
79
+ if (!ok || seen.has(bare)) continue;
80
+ seen.add(bare);
81
+ out.push(id.includes("/") ? bare : id);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function llmRequestHeaders(target: {
87
+ providerId: string;
88
+ apiKey: string;
89
+ headers?: Record<string, string>;
90
+ }): Record<string, string> {
91
+ const extra = { ...(target.headers ?? {}) };
92
+ if (isKeylessProvider(target.providerId)) {
93
+ const headers: Record<string, string> = {
94
+ "content-type": "application/json",
95
+ "http-referer": "https://github.com/Jakevin/guild",
96
+ "x-title": "Guild",
97
+ "user-agent": "Guild/0.2.19",
98
+ ...extra,
99
+ };
100
+ delete headers.authorization;
101
+ delete headers.Authorization;
102
+ return headers;
103
+ }
104
+ const headers: Record<string, string> = {
105
+ "content-type": "application/json",
106
+ ...extra,
107
+ };
108
+ if (!headers.authorization && !headers.Authorization) {
109
+ headers.authorization = `Bearer ${target.apiKey}`;
110
+ }
111
+ return headers;
112
+ }
113
+
114
+ let liveMemo: { at: number; ids: string[] | null } | null = null;
115
+ const LIVE_TTL_MS = 300_000;
116
+
117
+ export async function fetchOpenCodeFreeModels(
118
+ timeoutMs = 4_000,
119
+ force = false,
120
+ ): Promise<string[] | null> {
121
+ const now = Date.now();
122
+ if (!force && liveMemo && now - liveMemo.at < LIVE_TTL_MS) {
123
+ return liveMemo.ids ? [...liveMemo.ids] : null;
124
+ }
125
+ try {
126
+ const res = await fetch(`${OPENCODE_FREE_BASE_URL}/models`, {
127
+ headers: {
128
+ accept: "application/json",
129
+ "user-agent": "Guild/0.2.19",
130
+ "http-referer": "https://github.com/Jakevin/guild",
131
+ "x-title": "Guild",
132
+ },
133
+ signal: AbortSignal.timeout(timeoutMs),
134
+ });
135
+ if (!res.ok) {
136
+ liveMemo = { at: now, ids: null };
137
+ return null;
138
+ }
139
+ const body = (await res.json()) as { data?: { id?: string }[] } | { id?: string }[];
140
+ const rows = Array.isArray(body) ? body : body.data;
141
+ const ids = filterOpenCodeFreeIds(
142
+ (rows ?? [])
143
+ .map((row) => (row && typeof row.id === "string" ? row.id : ""))
144
+ .filter(Boolean),
145
+ );
146
+ const result = ids.length ? ids : null;
147
+ liveMemo = { at: now, ids: result };
148
+ return result ? [...result] : null;
149
+ } catch {
150
+ liveMemo = { at: now, ids: null };
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /** Tests only. */
156
+ export function resetOpenCodeFreeMemo(): void {
157
+ liveMemo = null;
158
+ }
159
+
160
+ export type OpenCodeFreeProbe = {
161
+ id: string;
162
+ ok: boolean;
163
+ status: number;
164
+ reason?: string;
165
+ };
166
+
167
+ /** Health ping — not a turn timer. Muse /responses often needs ~7s. */
168
+ export const OPENCODE_FREE_PROBE_TIMEOUT_MS = 12_000;
169
+ const PROBE_CONCURRENCY = 4;
170
+
171
+ type FetchLike = typeof fetch;
172
+
173
+ export function selectOpenCodeFreeIds(
174
+ live: string[],
175
+ probe: OpenCodeFreeProbe[],
176
+ keepId?: string,
177
+ ): string[] {
178
+ const ok = new Set(probe.filter((row) => row.ok).map((row) => row.id));
179
+ const picked = live.filter((id) => ok.has(id));
180
+ if (keepId && live.includes(keepId) && !picked.includes(keepId) && picked.length) {
181
+ picked.push(keepId);
182
+ }
183
+ return picked;
184
+ }
185
+
186
+ export async function probeOpenCodeFreeModel(
187
+ id: string,
188
+ fetcher: FetchLike = fetch,
189
+ ): Promise<OpenCodeFreeProbe> {
190
+ const model = String(id || "").trim();
191
+ if (!model) return { id: model, ok: false, status: 0, reason: "empty" };
192
+ const responses = usesZenResponses(model);
193
+ const url = `${OPENCODE_FREE_BASE_URL}${responses ? "/responses" : "/chat/completions"}`;
194
+ const body = responses
195
+ ? { model, input: [{ role: "user", content: "ping" }] }
196
+ : {
197
+ model,
198
+ messages: [{ role: "user", content: "ping" }],
199
+ max_tokens: 16,
200
+ };
201
+ try {
202
+ const res = await fetcher(url, {
203
+ method: "POST",
204
+ headers: llmRequestHeaders({
205
+ providerId: OPENCODE_FREE_PROVIDER_ID,
206
+ apiKey: "",
207
+ }),
208
+ body: JSON.stringify(body),
209
+ signal: AbortSignal.timeout(OPENCODE_FREE_PROBE_TIMEOUT_MS),
210
+ });
211
+ if (res.status === 429) {
212
+ return { id: model, ok: true, status: 429, reason: "rate-limited" };
213
+ }
214
+ if (!res.ok) {
215
+ return { id: model, ok: false, status: res.status, reason: `HTTP ${res.status}` };
216
+ }
217
+ return { id: model, ok: true, status: res.status };
218
+ } catch (err) {
219
+ const message = err instanceof Error ? err.message : String(err);
220
+ const timeout = /timeout|aborted/i.test(message);
221
+ return {
222
+ id: model,
223
+ ok: false,
224
+ status: 0,
225
+ reason: timeout ? "timeout" : message || "error",
226
+ };
227
+ }
228
+ }
229
+
230
+ export async function probeOpenCodeFreeModels(
231
+ ids: string[],
232
+ fetcher: FetchLike = fetch,
233
+ ): Promise<OpenCodeFreeProbe[]> {
234
+ const list = ids.filter(Boolean);
235
+ const results: OpenCodeFreeProbe[] = new Array(list.length);
236
+ let next = 0;
237
+ const worker = async () => {
238
+ while (true) {
239
+ const i = next++;
240
+ if (i >= list.length) return;
241
+ results[i] = await probeOpenCodeFreeModel(list[i], fetcher);
242
+ }
243
+ };
244
+ const n = Math.min(PROBE_CONCURRENCY, Math.max(list.length, 0));
245
+ await Promise.all(Array.from({ length: n }, () => worker()));
246
+ return results;
247
+ }