@jiayunxie/aerial 0.2.5 → 0.2.7

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,425 @@
1
+ import readline from "node:readline";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { proxyModels } from "../proxy/index.js";
4
+ import { readJsonSafely } from "../shared/utils.js";
5
+ import { modelsForRoute } from "../proxy/models.js";
6
+ import { codexStatus, claudeStatus } from "../setup/clients.js";
7
+ import {
8
+ CODEX_EFFORT_VALUES,
9
+ DEFAULT_EFFORT,
10
+ EFFORT_VALUES,
11
+ assertValidCodexEffort,
12
+ assertValidEffort,
13
+ normalizeCodexEffort,
14
+ normalizeEffort,
15
+ resolveCodexEffort
16
+ } from "../shared/effort.js";
17
+
18
+ export { modelsForRoute } from "../proxy/models.js";
19
+ export {
20
+ CODEX_EFFORT_VALUES,
21
+ DEFAULT_EFFORT,
22
+ EFFORT_VALUES,
23
+ assertValidCodexEffort,
24
+ assertValidEffort,
25
+ normalizeCodexEffort,
26
+ normalizeEffort,
27
+ resolveCodexEffort
28
+ } from "../shared/effort.js";
29
+
30
+ const MAX_LISTED_MODELS = 20;
31
+ const GPT_VERSION_RE = /^gpt-(\d+)(?:\.(\d+))?/i;
32
+ const STABLE_GPT_RE = /^gpt-\d+(?:\.\d+)?$/i;
33
+ const CODEX_EFFORT_USAGE = "<minimal|low|medium|high|xhigh|max|ultra>";
34
+ const CLAUDE_EFFORT_USAGE = "<low|medium|high|xhigh|max>";
35
+
36
+ const CLEAR_LINE = "\x1b[2K";
37
+ const CURSOR_UP = "\x1b[1A";
38
+ const HIDE_CURSOR = "\x1b[?25l";
39
+ const SHOW_CURSOR = "\x1b[?25h";
40
+
41
+ const MAX_VISIBLE = 10;
42
+
43
+ // Color is opt-out via NO_COLOR and gated on a real TTY, so piped output and
44
+ // dumb terminals get clean ASCII instead of escape-code soup.
45
+ const COLOR = process.env.NO_COLOR ? false : Boolean(output.isTTY);
46
+
47
+ const sgr = (code) => (COLOR ? `\x1b[${code}m` : "");
48
+ const RESET = sgr(0);
49
+ const BOLD = sgr(1);
50
+ const DIM = sgr(2);
51
+ const CYAN = sgr(36);
52
+ const BRIGHT_CYAN = sgr(96);
53
+ const GREEN = sgr(32);
54
+ const BLUE = sgr(34);
55
+ const YELLOW = sgr(33);
56
+
57
+ const GLYPHS = COLOR
58
+ ? { bar: "▌", on: "●", off: "○", arrow: "↑", darrow: "↓", enter: "⏎" }
59
+ : { bar: "|", on: "*", off: " ", arrow: "^", darrow: "v", enter: "<-" };
60
+
61
+ const TAG_COLORS = {
62
+ recommended: GREEN,
63
+ current: BLUE,
64
+ selected: BLUE,
65
+ default: YELLOW
66
+ };
67
+
68
+ function paintTags(tags) {
69
+ if (!tags.length) return "";
70
+ const painted = tags.map((t) => `${TAG_COLORS[t] || DIM}${t}${RESET}`).join(`${DIM}, ${RESET}`);
71
+ return ` ${painted}`;
72
+ }
73
+
74
+ function helpBar() {
75
+ const key = (s) => `${DIM}${s}${RESET}`;
76
+ return `${key(`${GLYPHS.arrow}${GLYPHS.darrow}`)} move ${key(GLYPHS.enter)} select ${key("q")} cancel`;
77
+ }
78
+
79
+ export function viewportStart(cursor, total, maxVisible) {
80
+ if (total <= maxVisible) return 0;
81
+ const half = Math.floor(maxVisible / 2);
82
+ const start = cursor - half;
83
+ if (start < 0) return 0;
84
+ if (start + maxVisible > total) return total - maxVisible;
85
+ return start;
86
+ }
87
+
88
+ function buildFrame({ title, items, cursor, getTags, maxVisible = MAX_VISIBLE }) {
89
+ const lines = [`${BOLD}${title}${RESET}`, ""];
90
+ const total = items.length;
91
+ const start = viewportStart(cursor, total, maxVisible);
92
+ const end = Math.min(start + maxVisible, total);
93
+ // Overflow hints occupy a fixed line whether or not there is overflow, so the
94
+ // frame height stays constant and incremental redraws never leave stale rows.
95
+ lines.push(start > 0 ? `${DIM} ${GLYPHS.arrow} ${start} more${RESET}` : "");
96
+ for (let index = start; index < end; index++) {
97
+ const item = items[index];
98
+ const active = index === cursor;
99
+ const tagText = paintTags(getTags ? getTags(item, index) : []);
100
+ if (active) {
101
+ const bar = `${BRIGHT_CYAN}${GLYPHS.bar}${RESET}`;
102
+ const dot = `${BRIGHT_CYAN}${GLYPHS.on}${RESET}`;
103
+ lines.push(`${bar} ${dot} ${CYAN}${BOLD}${item.label}${RESET}${tagText}`);
104
+ } else {
105
+ lines.push(` ${DIM}${GLYPHS.off}${RESET} ${item.label}${tagText}`);
106
+ }
107
+ }
108
+ lines.push(end < total ? `${DIM} ${GLYPHS.darrow} ${total - end} more${RESET}` : "");
109
+ lines.push("");
110
+ lines.push(helpBar());
111
+ return lines;
112
+ }
113
+
114
+ /**
115
+ * Arrow-key list selector. TTY-only; callers gate on isTTY and supply their own
116
+ * non-interactive path. Number keys jump to that 1-based row and Enter confirms,
117
+ * so tests can drive it by feeding "3\n".
118
+ */
119
+ async function select({
120
+ title,
121
+ items,
122
+ initialIndex = 0,
123
+ getTags,
124
+ maxVisible = MAX_VISIBLE,
125
+ input: inputStream = input,
126
+ output: outputStream = output
127
+ } = {}) {
128
+ if (!items.length) throw new Error("select requires at least one item");
129
+ let cursor = Math.min(Math.max(initialIndex, 0), items.length - 1);
130
+ let drawn = 0;
131
+
132
+ const render = () => {
133
+ if (drawn) outputStream.write(CURSOR_UP.repeat(drawn - 1) + "\r");
134
+ const lines = buildFrame({ title, items, cursor, getTags, maxVisible });
135
+ outputStream.write(lines.map((line) => CLEAR_LINE + line).join("\n"));
136
+ drawn = lines.length;
137
+ };
138
+
139
+ const wasRaw = Boolean(inputStream.isRaw);
140
+ const wasPaused = inputStream.isPaused();
141
+ readline.emitKeypressEvents(inputStream);
142
+ if (inputStream.isTTY && inputStream.setRawMode) inputStream.setRawMode(true);
143
+ inputStream.resume();
144
+ outputStream.write(HIDE_CURSOR);
145
+ render();
146
+
147
+ return await new Promise((resolve) => {
148
+ const cleanup = () => {
149
+ inputStream.removeListener("keypress", onKeypress);
150
+ if (inputStream.isTTY && inputStream.setRawMode) inputStream.setRawMode(wasRaw);
151
+ // Restore stdin to its prior flow state. emitKeypressEvents/resume left it
152
+ // flowing; if it started paused (the usual CLI case), re-pausing lets the
153
+ // event loop drain so the process exits after the final selection — while
154
+ // still allowing a subsequent select() in the same run to resume cleanly.
155
+ if (wasPaused) inputStream.pause();
156
+ outputStream.write("\n" + SHOW_CURSOR);
157
+ };
158
+ const onKeypress = (str, key) => {
159
+ if (!key) return;
160
+ if (key.name === "up" || key.name === "k") {
161
+ cursor = (cursor - 1 + items.length) % items.length;
162
+ render();
163
+ } else if (key.name === "down" || key.name === "j") {
164
+ cursor = (cursor + 1) % items.length;
165
+ render();
166
+ } else if (key.name === "return" || key.name === "enter") {
167
+ cleanup();
168
+ resolve({ index: cursor, item: items[cursor], cancelled: false });
169
+ } else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
170
+ cleanup();
171
+ resolve({ index: cursor, item: items[cursor], cancelled: true });
172
+ } else if (/^[1-9]$/.test(str || "")) {
173
+ const target = Number(str) - 1;
174
+ if (target < items.length) {
175
+ cursor = target;
176
+ render();
177
+ }
178
+ }
179
+ };
180
+ inputStream.on("keypress", onKeypress);
181
+ });
182
+ }
183
+
184
+ function gptVersionScore(id) {
185
+ const match = GPT_VERSION_RE.exec(id);
186
+ if (!match) return undefined;
187
+ const major = Number(match[1]);
188
+ const minor = match[2] === undefined ? 0 : Number(match[2]);
189
+ return major * 1000 + minor;
190
+ }
191
+
192
+ export function rankModels(choices) {
193
+ return [...choices]
194
+ .map((choice, index) => ({ choice, index, score: gptVersionScore(choice.id) }))
195
+ .sort((a, b) => {
196
+ if (a.score !== undefined && b.score !== undefined && a.score !== b.score) return b.score - a.score;
197
+ if (a.score !== undefined && b.score === undefined) return -1;
198
+ if (a.score === undefined && b.score !== undefined) return 1;
199
+ return a.index - b.index;
200
+ })
201
+ .map((entry) => entry.choice);
202
+ }
203
+
204
+ export function pickRecommended(choices) {
205
+ if (!choices.length) return { recommended: undefined, source: "first_available" };
206
+ const ranked = rankModels(choices);
207
+ const stable = ranked.filter((c) => STABLE_GPT_RE.test(c.id));
208
+ if (stable.length) return { recommended: stable[0].id, source: "recommended_stable", ranked };
209
+ return { recommended: ranked[0].id, source: "recommended_fallback", ranked };
210
+ }
211
+
212
+ export function orderForPrompt(ranked, recommended) {
213
+ if (!recommended) return [...ranked];
214
+ const found = ranked.find((c) => c.id === recommended);
215
+ if (!found) return [...ranked];
216
+ return [found, ...ranked.filter((c) => c.id !== recommended)];
217
+ }
218
+
219
+ export async function discoverModelsForRoute(route) {
220
+ const response = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
221
+ const payload = await readJsonSafely(response);
222
+ if (!response.ok) {
223
+ const detail = payload.error || payload.raw || JSON.stringify(payload);
224
+ throw new Error(`Could not load Copilot models (${response.status}): ${detail}`);
225
+ }
226
+ const models = Array.isArray(payload.data) ? payload.data : [];
227
+ return modelsForRoute(models, route);
228
+ }
229
+
230
+ function currentModelFor(target) {
231
+ try {
232
+ const status = target === "Codex" ? codexStatus() : claudeStatus();
233
+ return typeof status.model === "string" ? status.model : undefined;
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ }
238
+
239
+ function currentEffortFor(target) {
240
+ try {
241
+ const status = target === "Codex" ? codexStatus() : claudeStatus();
242
+ if (typeof status.effort !== "string") return undefined;
243
+ return target === "Codex" ? normalizeCodexEffort(status.effort) : normalizeEffort(status.effort);
244
+ } catch {
245
+ return undefined;
246
+ }
247
+ }
248
+
249
+ export function normalizeEffortCandidates(values) {
250
+ if (!Array.isArray(values)) return [];
251
+ const normalized = new Set();
252
+ for (const value of values) {
253
+ const effort = normalizeEffort(value);
254
+ if (effort) normalized.add(effort);
255
+ }
256
+ return EFFORT_VALUES.filter((effort) => normalized.has(effort));
257
+ }
258
+
259
+ export function normalizeCodexEffortCandidates(values) {
260
+ if (!Array.isArray(values)) return [];
261
+ const normalized = new Set();
262
+ for (const value of values) {
263
+ const effort = normalizeCodexEffort(value);
264
+ if (effort) normalized.add(effort);
265
+ }
266
+ return CODEX_EFFORT_VALUES.filter((effort) => normalized.has(effort));
267
+ }
268
+
269
+ function effortUsage(target, candidates, restricted) {
270
+ if (!restricted) return target === "Codex" ? CODEX_EFFORT_USAGE : CLAUDE_EFFORT_USAGE;
271
+ if (target === "Codex") return `<${candidates.join("|")}>`;
272
+ const values = [...candidates];
273
+ if (values.includes("xhigh")) values.push("max");
274
+ return `<${values.join("|")}>`;
275
+ }
276
+
277
+ function defaultEffortFor(candidates) {
278
+ return candidates.includes(DEFAULT_EFFORT) ? DEFAULT_EFFORT : candidates[0];
279
+ }
280
+
281
+ export async function chooseSetupModel({
282
+ target,
283
+ route,
284
+ explicitModel,
285
+ prompt,
286
+ input: inputStream = input,
287
+ output: outputStream = output
288
+ } = {}) {
289
+ if (explicitModel) return { model: explicitModel, choices: [], source: "explicit" };
290
+ let raw;
291
+ try {
292
+ raw = await discoverModelsForRoute(route);
293
+ } catch (err) {
294
+ throw new Error(`${err.message}\nRun \`aerial login\` first, or pass \`--model <id>\` if you already know which ${target} model to use.`);
295
+ }
296
+ if (!raw.length) {
297
+ throw new Error(`No Copilot models currently advertise the ${route} route needed by ${target}. Run \`aerial probe\` to inspect the full model list.`);
298
+ }
299
+ const { recommended, source: recommendedSource, ranked } = pickRecommended(raw);
300
+ const choices = ranked;
301
+ const promptListed = orderForPrompt(ranked, recommended);
302
+ const shouldPrompt = prompt === undefined ? Boolean(inputStream.isTTY) : prompt;
303
+ if (!shouldPrompt) return { model: recommended, choices, source: recommendedSource, recommended };
304
+
305
+ const current = currentModelFor(target);
306
+ const items = promptListed.map((choice) => ({ label: choice.id, id: choice.id }));
307
+ const currentIndex = items.findIndex((item) => item.id === current);
308
+ const initialIndex = currentIndex >= 0 ? currentIndex : 0;
309
+
310
+ outputStream.write(`Available ${target} models (${route} route):\n`);
311
+ if (recommendedSource === "recommended_fallback") {
312
+ outputStream.write(` No stable gpt-N.M model available; ${recommended} is the fallback. Pass --model <id> to override.\n`);
313
+ }
314
+
315
+ const { item, cancelled } = await select({
316
+ title: `Choose ${target} model:`,
317
+ items,
318
+ initialIndex,
319
+ getTags: (it) => {
320
+ const tags = [];
321
+ if (it.id === recommended) tags.push("recommended");
322
+ if (it.id === current) tags.push("current");
323
+ return tags;
324
+ },
325
+ input: inputStream,
326
+ output: outputStream
327
+ });
328
+ if (cancelled) throw new Error(`${target} setup cancelled.`);
329
+ return { model: item.id, choices, source: "prompt", displayed: true, recommended };
330
+ }
331
+
332
+ export function formatModelChoices({ target, route, choices, selectedModel, source, recommended }) {
333
+ if (!choices.length) return [];
334
+ const lines = [
335
+ `Available ${target} models (${route} route):`
336
+ ];
337
+ for (const [index, choice] of choices.slice(0, MAX_LISTED_MODELS).entries()) {
338
+ const markers = [];
339
+ if (choice.id === recommended) markers.push("recommended");
340
+ if (choice.id === selectedModel) markers.push("selected");
341
+ const suffix = markers.length ? ` (${markers.join(", ")})` : "";
342
+ lines.push(` ${index + 1}. ${choice.id}${suffix}`);
343
+ }
344
+ if (choices.length > MAX_LISTED_MODELS) lines.push(` ... ${choices.length - MAX_LISTED_MODELS} more`);
345
+ if (source === "first_available" || source === "recommended_stable") {
346
+ lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
347
+ } else if (source === "recommended_fallback") {
348
+ lines.push(`No stable gpt-N.M model available; selected ${selectedModel}. Pass --model <id> to override.`);
349
+ } else {
350
+ lines.push(`Selected ${target} model: ${selectedModel}`);
351
+ }
352
+ return lines;
353
+ }
354
+
355
+ export async function chooseSetupEffort({
356
+ target,
357
+ explicitEffort,
358
+ model,
359
+ supportedEfforts,
360
+ prompt,
361
+ input: inputStream = input,
362
+ output: outputStream = output
363
+ } = {}) {
364
+ const isCodex = target === "Codex";
365
+ const restrictedEfforts = isCodex
366
+ ? normalizeCodexEffortCandidates(supportedEfforts)
367
+ : normalizeEffortCandidates(supportedEfforts);
368
+ const restricted = restrictedEfforts.length > 0;
369
+ const globalEfforts = isCodex ? CODEX_EFFORT_VALUES : EFFORT_VALUES;
370
+ const candidates = restricted ? restrictedEfforts : globalEfforts;
371
+ if (explicitEffort !== undefined) {
372
+ if (isCodex) {
373
+ const requested = assertValidCodexEffort(explicitEffort);
374
+ const resolved = resolveCodexEffort(requested, supportedEfforts);
375
+ return {
376
+ effort: resolved.resolvedEffort,
377
+ source: "explicit",
378
+ displayed: false,
379
+ supportedEfforts: restricted ? candidates : undefined
380
+ };
381
+ }
382
+ const effort = assertValidEffort(explicitEffort);
383
+ if (restricted && !candidates.includes(effort)) {
384
+ const subject = model ? `${target} model ${model}` : `${target} model`;
385
+ throw new Error(`Effort ${JSON.stringify(explicitEffort)} is not supported by ${subject}. Allowed: ${effortUsage(target, candidates, true)}.`);
386
+ }
387
+ return { effort, source: "explicit", displayed: false, supportedEfforts: restricted ? candidates : undefined };
388
+ }
389
+ const shouldPrompt = prompt === undefined ? Boolean(inputStream.isTTY) : prompt;
390
+ if (!shouldPrompt) {
391
+ return { effort: defaultEffortFor(candidates), source: "default_non_tty", displayed: false, supportedEfforts: restricted ? candidates : undefined };
392
+ }
393
+ const current = currentEffortFor(target);
394
+ const currentIndex = candidates.indexOf(current);
395
+ const defaultIndex = candidates.indexOf(DEFAULT_EFFORT);
396
+ const initialIndex = currentIndex >= 0 ? currentIndex : defaultIndex >= 0 ? defaultIndex : 0;
397
+ const { item, cancelled } = await select({
398
+ title: `Choose ${target} reasoning effort:`,
399
+ items: candidates.map((value) => ({ label: value, value })),
400
+ initialIndex,
401
+ getTags: (it) => {
402
+ const tags = [];
403
+ if (it.value === DEFAULT_EFFORT) tags.push("default");
404
+ if (it.value === current) tags.push("current");
405
+ return tags;
406
+ },
407
+ input: inputStream,
408
+ output: outputStream
409
+ });
410
+ if (cancelled) throw new Error(`${target} setup cancelled.`);
411
+ const effort = isCodex
412
+ ? resolveCodexEffort(item.value, supportedEfforts).resolvedEffort
413
+ : item.value;
414
+ return { effort, source: "prompt", displayed: true, supportedEfforts: restricted ? candidates : undefined };
415
+ }
416
+
417
+ export function formatEffortSelection({ target, effort, source, supportedEfforts }) {
418
+ if (source === "explicit") return `Selected ${target} effort: ${effort}`;
419
+ if (source === "prompt") return `Selected ${target} effort: ${effort}`;
420
+ const restrictedEfforts = target === "Codex"
421
+ ? normalizeCodexEffortCandidates(supportedEfforts)
422
+ : normalizeEffortCandidates(supportedEfforts);
423
+ const restricted = restrictedEfforts.length > 0;
424
+ return `No interactive terminal detected; selected ${target} effort: ${effort}. Pass --effort ${effortUsage(target, restrictedEfforts, restricted)} to choose a different effort.`;
425
+ }
@@ -1,44 +1,97 @@
1
1
  import { loadConfig } from "../shared/config.js";
2
2
  import { logEvent } from "../shared/log.js";
3
- import { EFFORT_VALUES, normalizeEffort } from "../shared/effort.js";
4
- import { canonicalClaudeFamily, findCompatibleModel as findCompatibleModelShared } from "./model-catalog.js";
3
+ import { EFFORT_VALUES, normalizeCodexEffort, normalizeEffort, resolveCodexEffort } from "../shared/effort.js";
4
+ import {
5
+ canonicalClaudeFamily,
6
+ findCompatibleModel as findCompatibleModelShared,
7
+ supportedReasoningEfforts
8
+ } from "./model-catalog.js";
5
9
  import { withDefaultAnthropicCache, withDefaultPromptCache } from "./cache-policy.js";
6
10
 
7
- function openAIEffortRoute(model, effort) {
11
+ function openAIEffortRoute(model, effort, supportedEfforts) {
8
12
  if (effort === undefined) return undefined;
9
- const normalized = normalizeEffort(effort);
13
+ const normalized = normalizeCodexEffort(effort);
10
14
  if (!normalized) return undefined;
11
- if (/^gpt-5-mini(?:-|$)/.test(model) && normalized === "xhigh") return "high";
12
- if (normalized !== String(effort).trim().toLowerCase()) return normalized;
13
- return undefined;
15
+ const requested = String(effort).trim().toLowerCase();
16
+ const resolved = resolveCodexEffort(normalized, supportedEfforts);
17
+ if (!resolved) return undefined;
18
+ const hasSupportedEffortMetadata = Array.isArray(supportedEfforts)
19
+ && supportedEfforts.some((value) => normalizeCodexEffort(value));
20
+ if (!hasSupportedEffortMetadata && /^gpt-5-mini(?:-|$)/.test(model) && resolved.wireEffort === "xhigh") {
21
+ return { effort: "high", reason: "model_compatibility" };
22
+ }
23
+ if (resolved.wireEffort === requested) return undefined;
24
+ return { effort: resolved.wireEffort, reason: resolved.reason };
14
25
  }
15
26
 
16
- function withSupportedOpenAIEffort(payload) {
27
+ async function withSupportedOpenAIEffort(payload, loadModels) {
17
28
  const model = typeof payload?.model === "string" ? payload.model : "";
18
29
  const reasoningEffort = payload?.reasoning && typeof payload.reasoning === "object" ? payload.reasoning.effort : undefined;
19
- const nextReasoningEffort = openAIEffortRoute(model, reasoningEffort);
20
- const nextFlatEffort = openAIEffortRoute(model, payload?.reasoning_effort);
30
+ const flatEffort = payload?.reasoning_effort;
31
+ const hasKnownEffort = normalizeCodexEffort(reasoningEffort) || normalizeCodexEffort(flatEffort);
32
+ const models = hasKnownEffort && typeof loadModels === "function"
33
+ ? await loadModels().catch(() => undefined)
34
+ : undefined;
35
+ const selectedModel = Array.isArray(models) ? models.find((entry) => entry?.id === model) : undefined;
36
+ const supportedEfforts = selectedModel ? supportedReasoningEfforts(selectedModel) : undefined;
37
+ const nextReasoningEffort = openAIEffortRoute(model, reasoningEffort, supportedEfforts);
38
+ const nextFlatEffort = openAIEffortRoute(model, flatEffort, supportedEfforts);
21
39
  if (!nextReasoningEffort && !nextFlatEffort) return payload;
22
40
 
23
41
  const next = { ...payload };
24
- if (nextReasoningEffort) next.reasoning = { ...payload.reasoning, effort: nextReasoningEffort };
25
- if (nextFlatEffort) next.reasoning_effort = nextFlatEffort;
42
+ if (nextReasoningEffort) next.reasoning = { ...payload.reasoning, effort: nextReasoningEffort.effort };
43
+ if (nextFlatEffort) next.reasoning_effort = nextFlatEffort.effort;
26
44
  logEvent("openai_effort_route", {
27
45
  model,
28
- effort: reasoningEffort ?? payload?.reasoning_effort,
29
- routedEffort: nextReasoningEffort ?? nextFlatEffort
46
+ effort: reasoningEffort ?? flatEffort,
47
+ routedEffort: nextReasoningEffort?.effort ?? nextFlatEffort?.effort,
48
+ reason: nextReasoningEffort?.reason ?? nextFlatEffort?.reason
30
49
  });
31
50
  return next;
32
51
  }
33
52
 
34
- export function withOpenAIDefaults(payload) {
35
- return withDefaultPromptCache(withSupportedOpenAIEffort(payload));
53
+ export async function withOpenAIDefaults(payload, loadModels) {
54
+ return withDefaultPromptCache(await withSupportedOpenAIEffort(payload, loadModels));
36
55
  }
37
56
 
38
57
  function objectOrEmpty(value) {
39
58
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
40
59
  }
41
60
 
61
+ function closestSupportedEffort(model, requestedEffort) {
62
+ const requestedRank = EFFORT_VALUES.indexOf(requestedEffort);
63
+ if (requestedRank === -1) return undefined;
64
+ const supported = supportedReasoningEfforts(model)
65
+ .map((effort) => normalizeEffort(effort))
66
+ .filter((effort, index, values) => effort && values.indexOf(effort) === index);
67
+ if (supported.includes(requestedEffort)) return requestedEffort;
68
+ if (supported.length === 0) return undefined;
69
+ const ranked = supported
70
+ .map((effort) => ({ effort, rank: EFFORT_VALUES.indexOf(effort) }))
71
+ .filter((entry) => entry.rank !== -1)
72
+ .sort((a, b) => a.rank - b.rank);
73
+ const lowerOrEqual = ranked.filter((entry) => entry.rank <= requestedRank);
74
+ return (lowerOrEqual.at(-1) || ranked[0])?.effort;
75
+ }
76
+
77
+ function preferredModel(models, modelId) {
78
+ if (!Array.isArray(models)) return undefined;
79
+ return models.find((model) => model?.id === modelId);
80
+ }
81
+
82
+ function fallbackModelForFamily(models, family, preferredId) {
83
+ const exact = preferredModel(models, preferredId);
84
+ if (exact) return exact;
85
+ const base = preferredModel(models, family);
86
+ if (!base) return undefined;
87
+ return findCompatibleModelShared({
88
+ models: [base],
89
+ family,
90
+ route: "/v1/messages",
91
+ adaptiveThinking: true
92
+ });
93
+ }
94
+
42
95
  function withSupportedAnthropicEffort(payload, models) {
43
96
  const effort = payload?.output_config?.effort;
44
97
  if (effort === undefined) return payload;
@@ -47,18 +100,33 @@ function withSupportedAnthropicEffort(payload, models) {
47
100
  if (!family) return payload;
48
101
  const nextEffort = normalizeEffort(effort);
49
102
  if (!nextEffort || !EFFORT_VALUES.includes(nextEffort)) return payload;
50
- const routed = findCompatibleModelShared({
103
+ let routedEffort = nextEffort;
104
+ let routed = findCompatibleModelShared({
51
105
  models,
52
106
  family,
53
107
  route: "/v1/messages",
54
108
  adaptiveThinking: true,
55
- effort: nextEffort,
109
+ effort: routedEffort,
56
110
  preferredId: model
57
111
  });
112
+ if (!routed) {
113
+ const fallbackEffort = closestSupportedEffort(fallbackModelForFamily(models, family, model), nextEffort);
114
+ if (fallbackEffort && fallbackEffort !== nextEffort) {
115
+ routedEffort = fallbackEffort;
116
+ routed = findCompatibleModelShared({
117
+ models,
118
+ family,
119
+ route: "/v1/messages",
120
+ adaptiveThinking: true,
121
+ effort: routedEffort,
122
+ preferredId: model
123
+ });
124
+ }
125
+ }
58
126
  const nextModel = routed?.id || model;
59
- if (model === nextModel && effort === nextEffort) return payload;
60
- logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort: nextEffort });
61
- return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort: nextEffort } };
127
+ if (model === nextModel && effort === routedEffort) return payload;
128
+ logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort });
129
+ return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort: routedEffort } };
62
130
  }
63
131
 
64
132
  function legacyThinkingEffort(thinking) {
@@ -24,7 +24,7 @@ export async function proxyModels(request) {
24
24
  }
25
25
 
26
26
  export async function proxyResponses(request) {
27
- const payload = withOpenAIDefaults(await request.json());
27
+ const payload = await withOpenAIDefaults(await request.json(), fetchModelsCatalogForCopilot);
28
28
  const body = Buffer.from(JSON.stringify(payload));
29
29
 
30
30
  if (payload?.stream && isResponsesWebSocketOptIn()) {
@@ -67,8 +67,8 @@ export async function proxyMessages(request) {
67
67
  }
68
68
 
69
69
  export async function proxyChatCompletions(request) {
70
- const upstreamRequest = await requestWithJsonBody(request, (payload) => {
71
- payload = withOpenAIDefaults(payload);
70
+ const upstreamRequest = await requestWithJsonBody(request, async (payload) => {
71
+ payload = await withOpenAIDefaults(payload, fetchModelsCatalogForCopilot);
72
72
  if (payload.max_tokens !== undefined && payload.max_completion_tokens === undefined) {
73
73
  const { max_tokens, ...rest } = payload;
74
74
  return { ...rest, max_completion_tokens: max_tokens };
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { logEvent } from "../shared/log.js";
2
3
 
3
4
  const TTL_MS = 30000;
4
5
  const cache = new Map();
@@ -8,14 +9,21 @@ export function tokenFingerprintOf(token) {
8
9
  return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16);
9
10
  }
10
11
 
11
- export async function fetchModelsCatalog({ fetchImpl, tokenFingerprint } = {}) {
12
+ export async function fetchModelsCatalog({ fetchImpl, tokenFingerprint, now = Date.now() } = {}) {
12
13
  if (typeof fetchImpl !== "function") return undefined;
13
14
  const key = tokenFingerprint || "anonymous";
14
15
  const cached = cache.get(key);
15
- const now = Date.now();
16
16
  if (cached && cached.expiresAt > now) return cached.models;
17
- const models = await fetchImpl();
18
- if (!Array.isArray(models)) return undefined;
17
+ let models;
18
+ try {
19
+ models = await fetchImpl();
20
+ } catch {
21
+ models = undefined;
22
+ }
23
+ if (!Array.isArray(models)) {
24
+ if (cached) logEvent("model_catalog_stale", { reason: "refresh_failed" });
25
+ return cached?.models;
26
+ }
19
27
  cache.set(key, { models, expiresAt: now + TTL_MS });
20
28
  return models;
21
29
  }
@@ -26,7 +34,8 @@ export function clearModelCatalogCacheForTests() {
26
34
 
27
35
  export function canonicalClaudeFamily(modelId) {
28
36
  if (typeof modelId !== "string") return undefined;
29
- if (/^claude-opus-4[.-]7(?:-|$)/.test(modelId)) return "claude-opus-4.7";
37
+ const opus = modelId.match(/^claude-opus-4[.-](\d+)(?:-|$)/);
38
+ if (opus) return `claude-opus-4.${opus[1]}`;
30
39
  return undefined;
31
40
  }
32
41
 
@@ -42,7 +51,7 @@ function modelSupportsAdaptiveThinking(model) {
42
51
  return supports?.adaptive_thinking === true || supports?.thinking?.adaptive === true;
43
52
  }
44
53
 
45
- function supportedReasoningEfforts(model) {
54
+ export function supportedReasoningEfforts(model) {
46
55
  const supports = model?.capabilities?.supports;
47
56
  const values = supports?.reasoning_effort ?? supports?.reasoning_efforts;
48
57
  if (Array.isArray(values)) return values.map(String);
@@ -1,4 +1,5 @@
1
1
  import { copyResponseHeaders } from "./headers.js";
2
+ import { supportedReasoningEfforts } from "./model-catalog.js";
2
3
 
3
4
  export function aerialSupportForModel(model) {
4
5
  const endpoints = Array.isArray(model.supported_endpoints) ? model.supported_endpoints : [];
@@ -27,3 +28,32 @@ export async function annotateModelsResponse(response) {
27
28
  data: payload.data.map((model) => ({ ...model, aerial: aerialSupportForModel(model) }))
28
29
  }, { status: response.status, headers: copyResponseHeaders(response) });
29
30
  }
31
+
32
+ export function aerialRoutes(model) {
33
+ return Array.isArray(model?.aerial?.routes) ? model.aerial.routes : [];
34
+ }
35
+
36
+ export function modelsForRoute(models, route) {
37
+ return models
38
+ .filter((model) => typeof model?.id === "string" && aerialRoutes(model).includes(route))
39
+ .map((model) => {
40
+ const supportedEfforts = supportedReasoningEfforts(model);
41
+ return {
42
+ id: model.id,
43
+ routes: aerialRoutes(model),
44
+ notes: model.aerial?.notes || [],
45
+ ...(supportedEfforts.length ? { supportedEfforts } : {})
46
+ };
47
+ });
48
+ }
49
+
50
+ export function usageSummary(payload) {
51
+ const usage = payload?.usage || payload?.response?.usage || {};
52
+ return {
53
+ input: usage.input_tokens ?? usage.prompt_tokens,
54
+ output: usage.output_tokens ?? usage.completion_tokens,
55
+ cached: usage.input_tokens_details?.cached_tokens
56
+ ?? usage.prompt_tokens_details?.cached_tokens
57
+ ?? usage.cache_read_input_tokens
58
+ };
59
+ }