@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.
- package/README.md +9 -5
- package/docs/usage.md +7 -5
- package/package.json +2 -2
- package/src/cli/commands/config.js +1 -1
- package/src/cli/commands/setup.js +39 -8
- package/src/cli/commands/start.js +1 -1
- package/src/cli/commands/status.js +1 -1
- package/src/cli/commands/{disable.js → teardown.js} +2 -2
- package/src/cli/doctor.js +1 -1
- package/src/cli/helpers.js +92 -0
- package/src/cli/index.js +41 -9
- package/src/cli/output.js +1 -0
- package/src/cli/probe.js +6 -3
- package/src/cli/select.js +425 -0
- package/src/proxy/effort-routing.js +89 -21
- package/src/proxy/index.js +3 -3
- package/src/proxy/model-catalog.js +15 -6
- package/src/proxy/models.js +30 -0
- package/src/service/platform.js +1 -1
- package/src/setup/clients.js +24 -14
- package/src/setup/index.js +26 -3
- package/src/setup/restore.js +38 -2
- package/src/setup/toml.js +70 -19
- package/src/shared/effort.js +70 -1
- package/src/shared/paths.js +1 -1
- package/src/shared/{file-utils.js → utils.js} +19 -0
- package/src/cli/app-status.js +0 -23
- package/src/cli/args.js +0 -28
- package/src/cli/help.js +0 -23
- package/src/cli/model-selection.js +0 -119
- package/src/cli/runtime-auth.js +0 -21
- package/src/cli/setup-selection.js +0 -47
- package/src/cli/version.js +0 -19
- package/src/proxy/model-utils.js +0 -20
- package/src/setup/backup.js +0 -38
- package/src/setup/status.js +0 -24
- package/src/shared/http-utils.js +0 -9
- package/src/shared/prompt-utils.js +0 -8
|
@@ -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 {
|
|
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 =
|
|
13
|
+
const normalized = normalizeCodexEffort(effort);
|
|
10
14
|
if (!normalized) return undefined;
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
20
|
-
const
|
|
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 ??
|
|
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
|
-
|
|
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:
|
|
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 ===
|
|
60
|
-
logEvent("anthropic_effort_route", { model, effort, routedModel: nextModel, routedEffort
|
|
61
|
-
return { ...payload, model: nextModel, output_config: { ...payload.output_config, effort:
|
|
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) {
|
package/src/proxy/index.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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);
|
package/src/proxy/models.js
CHANGED
|
@@ -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
|
+
}
|