@goodtek/vibeops 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +5 -4
- package/dist/cli.js +32 -5
- package/dist/commands/plan.js +242 -13
- package/dist/commands/task-start.js +16 -4
- package/dist/lib/brief.js +11 -4
- package/dist/lib/git.js +42 -0
- package/dist/lib/plan-apply-planner.js +131 -0
- package/dist/lib/plan-codex-auth.js +183 -0
- package/dist/lib/plan-codex-pkce.js +234 -0
- package/dist/lib/plan-codex-responses.js +228 -0
- package/dist/lib/plan-llm-cursor-agent.js +59 -0
- package/dist/lib/plan-llm-detect.js +74 -0
- package/dist/lib/plan-llm-models.js +237 -0
- package/dist/lib/plan-llm-normalize.js +146 -0
- package/dist/lib/plan-llm-openai.js +123 -0
- package/dist/lib/plan-llm-session.js +681 -0
- package/dist/lib/plan-llm-types.js +1 -0
- package/dist/lib/plan-post-apply-setup.js +116 -0
- package/dist/lib/prompt-builder.js +11 -1
- package/package.json +1 -1
- package/templates/.cursor/rules/03-git-safety.mdc +1 -1
- package/templates/.vibeops/workflows/task-lifecycle.md +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT Codex backend (subscription OAuth) — POST /backend-api/codex/responses
|
|
3
|
+
* with store:false and stream:true (required for ChatGPT OAuth tokens).
|
|
4
|
+
*
|
|
5
|
+
* @see https://github.com/openclaw/openclaw/issues/67740 (payload constraints)
|
|
6
|
+
*/
|
|
7
|
+
import { resolveCodexOAuthAccessToken } from "./plan-codex-auth.js";
|
|
8
|
+
const DEFAULT_BASE = "https://chatgpt.com/backend-api/codex";
|
|
9
|
+
/** Base URL for Codex ChatGPT routes (`…/responses`, `…/models`). */
|
|
10
|
+
export function codexResponsesBaseUrl() {
|
|
11
|
+
return (process.env.VIBEOPS_CODEX_BASE_URL ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
12
|
+
}
|
|
13
|
+
/** ChatGPT-account Codex (`/backend-api/codex/responses`) does not accept API-only codex model ids such as `gpt-5.1-codex`. */
|
|
14
|
+
function codexModel() {
|
|
15
|
+
return process.env.VIBEOPS_CODEX_MODEL?.trim() || "gpt-5.4";
|
|
16
|
+
}
|
|
17
|
+
function mapChatMessagesToCodexBody(messages) {
|
|
18
|
+
const systemChunks = [];
|
|
19
|
+
const input = [];
|
|
20
|
+
for (const m of messages) {
|
|
21
|
+
if (m.role === "system") {
|
|
22
|
+
systemChunks.push(m.content);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (m.role === "user") {
|
|
26
|
+
input.push({
|
|
27
|
+
type: "message",
|
|
28
|
+
role: "user",
|
|
29
|
+
content: [{ type: "input_text", text: m.content }],
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (m.role === "assistant") {
|
|
34
|
+
input.push({
|
|
35
|
+
type: "message",
|
|
36
|
+
role: "assistant",
|
|
37
|
+
content: [{ type: "output_text", text: m.content }],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const instructions = systemChunks.join("\n\n").trim();
|
|
42
|
+
if (instructions.length === 0) {
|
|
43
|
+
throw new Error("Codex responses: at least one system message is required.");
|
|
44
|
+
}
|
|
45
|
+
return { instructions, input };
|
|
46
|
+
}
|
|
47
|
+
function deltaTextFromEvent(obj) {
|
|
48
|
+
const d = obj.delta;
|
|
49
|
+
if (typeof d === "string")
|
|
50
|
+
return d;
|
|
51
|
+
if (d && typeof d === "object") {
|
|
52
|
+
const t = d.text;
|
|
53
|
+
if (typeof t === "string")
|
|
54
|
+
return t;
|
|
55
|
+
}
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parses SSE from Codex responses stream; accumulates assistant text deltas.
|
|
60
|
+
*/
|
|
61
|
+
export async function readSseTextBody(response) {
|
|
62
|
+
const body = response.body;
|
|
63
|
+
if (!body) {
|
|
64
|
+
const t = await response.text();
|
|
65
|
+
return t;
|
|
66
|
+
}
|
|
67
|
+
const reader = body.getReader();
|
|
68
|
+
const dec = new TextDecoder();
|
|
69
|
+
let pending = "";
|
|
70
|
+
let out = "";
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done)
|
|
74
|
+
break;
|
|
75
|
+
pending += dec.decode(value, { stream: true });
|
|
76
|
+
let sep;
|
|
77
|
+
while ((sep = pending.indexOf("\n\n")) >= 0) {
|
|
78
|
+
const block = pending.slice(0, sep);
|
|
79
|
+
pending = pending.slice(sep + 2);
|
|
80
|
+
for (const line of block.split("\n")) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed.startsWith("data:"))
|
|
83
|
+
continue;
|
|
84
|
+
const data = trimmed.slice(5).trim();
|
|
85
|
+
if (data === "[DONE]")
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
const j = JSON.parse(data);
|
|
89
|
+
const typ = String(j.type ?? "");
|
|
90
|
+
if (typ === "response.output_text.delta" || typ.endsWith(".output_text.delta")) {
|
|
91
|
+
out += deltaTextFromEvent(j);
|
|
92
|
+
}
|
|
93
|
+
if (typ === "response.completed" || typ === "response.done") {
|
|
94
|
+
const resp = j.response;
|
|
95
|
+
if (resp && typeof resp === "object") {
|
|
96
|
+
const output = resp.output;
|
|
97
|
+
if (Array.isArray(output)) {
|
|
98
|
+
for (const item of output) {
|
|
99
|
+
if (!item || typeof item !== "object")
|
|
100
|
+
continue;
|
|
101
|
+
const content = item.content;
|
|
102
|
+
if (!Array.isArray(content))
|
|
103
|
+
continue;
|
|
104
|
+
for (const c of content) {
|
|
105
|
+
if (!c || typeof c !== "object")
|
|
106
|
+
continue;
|
|
107
|
+
const o = c;
|
|
108
|
+
if (o.type === "output_text" && typeof o.text === "string") {
|
|
109
|
+
out += o.text;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* ignore malformed SSE JSON */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
pending += dec.decode();
|
|
124
|
+
if (pending.trim().length > 0) {
|
|
125
|
+
for (const line of pending.split("\n")) {
|
|
126
|
+
const trimmed = line.trim();
|
|
127
|
+
if (!trimmed.startsWith("data:"))
|
|
128
|
+
continue;
|
|
129
|
+
const data = trimmed.slice(5).trim();
|
|
130
|
+
if (data === "[DONE]")
|
|
131
|
+
continue;
|
|
132
|
+
try {
|
|
133
|
+
const j = JSON.parse(data);
|
|
134
|
+
const typ = String(j.type ?? "");
|
|
135
|
+
if (typ === "response.output_text.delta" || typ.endsWith(".output_text.delta")) {
|
|
136
|
+
out += deltaTextFromEvent(j);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out.trim();
|
|
145
|
+
}
|
|
146
|
+
async function postCodexResponses(body) {
|
|
147
|
+
const { accessToken } = await resolveCodexOAuthAccessToken();
|
|
148
|
+
const url = `${codexResponsesBaseUrl()}/responses`;
|
|
149
|
+
return fetch(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${accessToken}`,
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
Accept: "text/event-stream",
|
|
155
|
+
"OpenAI-Beta": "responses=v1",
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify(body),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One planning model turn via Codex (ChatGPT OAuth). Returns raw assistant text
|
|
162
|
+
* (expected to be a single JSON object per VibeOps protocol).
|
|
163
|
+
*/
|
|
164
|
+
export async function codexOAuthPlanCompletion(messages, options) {
|
|
165
|
+
const { instructions, input } = mapChatMessagesToCodexBody(messages);
|
|
166
|
+
const model = options?.model?.trim() || codexModel();
|
|
167
|
+
const withJsonFormat = () => ({
|
|
168
|
+
model,
|
|
169
|
+
instructions,
|
|
170
|
+
input,
|
|
171
|
+
store: false,
|
|
172
|
+
stream: true,
|
|
173
|
+
text: {
|
|
174
|
+
format: {
|
|
175
|
+
type: "json_object",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const minimal = () => ({
|
|
180
|
+
model,
|
|
181
|
+
instructions,
|
|
182
|
+
input,
|
|
183
|
+
store: false,
|
|
184
|
+
stream: true,
|
|
185
|
+
});
|
|
186
|
+
let res = await postCodexResponses(withJsonFormat());
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
const errText = await res.text().catch(() => "");
|
|
189
|
+
if (res.status === 400 && /unknown|unsupported|text|format|parameter/i.test(errText)) {
|
|
190
|
+
res = await postCodexResponses(minimal());
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
const err2 = await res.text().catch(() => "");
|
|
193
|
+
throw new Error(`Codex responses HTTP ${res.status}: ${err2.slice(0, 500)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
throw new Error(`Codex responses HTTP ${res.status}: ${errText.slice(0, 500)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const text = await readSseTextBody(res);
|
|
201
|
+
if (text.length === 0) {
|
|
202
|
+
throw new Error("Codex responses stream produced empty text.");
|
|
203
|
+
}
|
|
204
|
+
return text;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* One assistant turn as free markdown (no JSON schema) — used for planner apply.
|
|
208
|
+
*/
|
|
209
|
+
export async function codexOAuthTextCompletion(messages, options) {
|
|
210
|
+
const { instructions, input } = mapChatMessagesToCodexBody(messages);
|
|
211
|
+
const model = options?.model?.trim() || codexModel();
|
|
212
|
+
const res = await postCodexResponses({
|
|
213
|
+
model,
|
|
214
|
+
instructions,
|
|
215
|
+
input,
|
|
216
|
+
store: false,
|
|
217
|
+
stream: true,
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
const errText = await res.text().catch(() => "");
|
|
221
|
+
throw new Error(`Codex responses HTTP ${res.status}: ${errText.slice(0, 500)}`);
|
|
222
|
+
}
|
|
223
|
+
const text = await readSseTextBody(res);
|
|
224
|
+
if (text.length === 0) {
|
|
225
|
+
throw new Error("Codex responses stream produced empty text.");
|
|
226
|
+
}
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
function agentBin() {
|
|
5
|
+
return process.env.VIBEOPS_CURSOR_AGENT_BIN?.trim() || "agent";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs Cursor Agent CLI in print mode with JSON output and plan mode.
|
|
9
|
+
* @see https://cursor.com/docs/cli/reference/parameters
|
|
10
|
+
*/
|
|
11
|
+
export async function cursorAgentPrint(params) {
|
|
12
|
+
const command = agentBin();
|
|
13
|
+
const argv = [
|
|
14
|
+
"--print",
|
|
15
|
+
"--output-format",
|
|
16
|
+
"json",
|
|
17
|
+
"--plan",
|
|
18
|
+
"--workspace",
|
|
19
|
+
params.cwd,
|
|
20
|
+
...(params.model?.trim() ? ["--model", params.model.trim()] : []),
|
|
21
|
+
"--trust",
|
|
22
|
+
params.prompt,
|
|
23
|
+
];
|
|
24
|
+
try {
|
|
25
|
+
const { stdout, stderr } = await execFileAsync(command, argv, {
|
|
26
|
+
cwd: params.cwd,
|
|
27
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
28
|
+
env: process.env,
|
|
29
|
+
});
|
|
30
|
+
const combined = `${stdout}${stderr}`.trim();
|
|
31
|
+
return combined.length > 0 ? combined : stdout;
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
const err = e;
|
|
35
|
+
const msg = [err.stderr, err.message].filter(Boolean).join("\n").trim();
|
|
36
|
+
throw new Error(msg.length > 0 ? msg : `${command} --print failed`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parses `agent --print --output-format json` stdout.
|
|
41
|
+
* Shape may be `{ "result": "..." }` or nested — try common keys then raw.
|
|
42
|
+
*/
|
|
43
|
+
export function extractAgentAssistantText(rawStdout) {
|
|
44
|
+
const trimmed = rawStdout.trim();
|
|
45
|
+
try {
|
|
46
|
+
const j = JSON.parse(trimmed);
|
|
47
|
+
for (const key of ["result", "response", "output", "text", "message", "content"]) {
|
|
48
|
+
const v = j[key];
|
|
49
|
+
if (typeof v === "string" && v.trim().length > 0)
|
|
50
|
+
return v.trim();
|
|
51
|
+
}
|
|
52
|
+
if (typeof j.assistant === "string")
|
|
53
|
+
return j.assistant.trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* fall through */
|
|
57
|
+
}
|
|
58
|
+
return trimmed;
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Confirms `OPENAI_API_KEY` is set and accepted by the OpenAI API (lightweight).
|
|
6
|
+
*/
|
|
7
|
+
export async function probeOpenAiApiKey() {
|
|
8
|
+
const key = process.env.OPENAI_API_KEY?.trim();
|
|
9
|
+
if (!key || key.length === 0) {
|
|
10
|
+
return { ok: false, reason: "OPENAI_API_KEY is not set." };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch("https://api.openai.com/v1/models?limit=1", {
|
|
14
|
+
method: "GET",
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${key}`,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => "");
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
reason: `OpenAI API returned ${res.status}. ${body.slice(0, 200)}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { ok: true };
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
30
|
+
return { ok: false, reason: `Network error: ${msg}` };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function agentBin() {
|
|
34
|
+
return process.env.VIBEOPS_CURSOR_AGENT_BIN?.trim() || "agent";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Checks that the Cursor Agent CLI is on PATH and `agent status` succeeds.
|
|
38
|
+
*/
|
|
39
|
+
export async function probeCursorAgentCli(cwd) {
|
|
40
|
+
const command = agentBin();
|
|
41
|
+
try {
|
|
42
|
+
const { stdout, stderr } = await execFileAsync(command, ["status"], {
|
|
43
|
+
cwd,
|
|
44
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
45
|
+
env: process.env,
|
|
46
|
+
});
|
|
47
|
+
const out = `${stdout}\n${stderr}`.trim();
|
|
48
|
+
if (/not\s+logged\s+in|login\s+required|unauthorized|no\s+auth/i.test(out)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
command,
|
|
52
|
+
stdout: out,
|
|
53
|
+
reason: "Cursor Agent CLI is installed but not authenticated.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, command, stdout: out };
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
const err = e;
|
|
60
|
+
if (err.code === "ENOENT") {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
command,
|
|
64
|
+
reason: `Command "${command}" not found on PATH. Install from https://cursor.com/docs/cli`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const msg = [err.stderr, err.message].filter(Boolean).join(" ").trim();
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
command,
|
|
71
|
+
reason: msg.length > 0 ? msg : "agent status failed",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive `vibeops plan`: discover model ids from the active provider
|
|
3
|
+
* (OpenAI / Codex ChatGPT OAuth / Cursor Agent CLI), then let the user pick.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { resolveCodexOAuthAccessToken } from "./plan-codex-auth.js";
|
|
8
|
+
import { codexResponsesBaseUrl } from "./plan-codex-responses.js";
|
|
9
|
+
import { askInput, askSelect } from "./inquirer-helpers.js";
|
|
10
|
+
import { dim, log, yellow } from "./logger.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const DEFAULT_PICK_LABEL = "(Use environment / VibeOps default)";
|
|
13
|
+
const MAX_OPENAI_MODELS = 100;
|
|
14
|
+
const MAX_CODEX_MODELS = 200;
|
|
15
|
+
const MAX_SELECT_CHOICES = 120;
|
|
16
|
+
/** Codex `GET /models` requires `client_version` (same query as official Codex CLI). */
|
|
17
|
+
const DEFAULT_CODEX_CLIENT_VERSION = "0.130.0";
|
|
18
|
+
async function resolveCodexClientVersion() {
|
|
19
|
+
const env = process.env.VIBEOPS_CODEX_CLIENT_VERSION?.trim();
|
|
20
|
+
if (env)
|
|
21
|
+
return env;
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFileAsync("codex", ["--version"], {
|
|
24
|
+
maxBuffer: 64 * 1024,
|
|
25
|
+
env: process.env,
|
|
26
|
+
timeout: 4000,
|
|
27
|
+
});
|
|
28
|
+
const line = stdout
|
|
29
|
+
.trim()
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.find((l) => l.trim().length > 0);
|
|
32
|
+
if (line) {
|
|
33
|
+
const m = /(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)/.exec(line);
|
|
34
|
+
if (m?.[1])
|
|
35
|
+
return m[1];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* codex not on PATH */
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_CODEX_CLIENT_VERSION;
|
|
42
|
+
}
|
|
43
|
+
function agentBin() {
|
|
44
|
+
return process.env.VIBEOPS_CURSOR_AGENT_BIN?.trim() || "agent";
|
|
45
|
+
}
|
|
46
|
+
/** Best-effort parse for Codex `/models` and similar JSON shapes. */
|
|
47
|
+
export function extractModelIdsFromUnknownJson(json) {
|
|
48
|
+
const ids = [];
|
|
49
|
+
const fromArray = (arr) => {
|
|
50
|
+
for (const item of arr) {
|
|
51
|
+
if (typeof item === "string" && item.trim().length > 0) {
|
|
52
|
+
ids.push(item.trim());
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
56
|
+
continue;
|
|
57
|
+
const o = item;
|
|
58
|
+
for (const k of ["id", "slug", "model", "name", "model_id"]) {
|
|
59
|
+
const v = o[k];
|
|
60
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
61
|
+
ids.push(v.trim());
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
if (Array.isArray(json)) {
|
|
68
|
+
fromArray(json);
|
|
69
|
+
}
|
|
70
|
+
else if (json && typeof json === "object") {
|
|
71
|
+
const o = json;
|
|
72
|
+
for (const key of ["data", "models", "items", "results"]) {
|
|
73
|
+
const a = o[key];
|
|
74
|
+
if (Array.isArray(a))
|
|
75
|
+
fromArray(a);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
|
|
79
|
+
}
|
|
80
|
+
function isReasonableOpenAiChatModel(id) {
|
|
81
|
+
const x = id.toLowerCase();
|
|
82
|
+
if (!x)
|
|
83
|
+
return false;
|
|
84
|
+
if (/embedding|embed|whisper|tts|realtime|transcribe|audio-in|audio-out|image|dall-e|moderation|davinci|babbage|ada-|text-search|code-search|omni-moderation/.test(x)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return /^gpt-|^o[0-9]|^chatgpt-/.test(x);
|
|
88
|
+
}
|
|
89
|
+
function rankOpenAiPlanModel(id) {
|
|
90
|
+
if (/gpt-5\.[45]/.test(id))
|
|
91
|
+
return 1000;
|
|
92
|
+
if (/gpt-5/.test(id))
|
|
93
|
+
return 900;
|
|
94
|
+
if (/gpt-4o/.test(id))
|
|
95
|
+
return 800;
|
|
96
|
+
if (/gpt-4/.test(id))
|
|
97
|
+
return 700;
|
|
98
|
+
if (/^o[0-9]/.test(id))
|
|
99
|
+
return 650;
|
|
100
|
+
if (/gpt-3/.test(id))
|
|
101
|
+
return 300;
|
|
102
|
+
return 100;
|
|
103
|
+
}
|
|
104
|
+
export async function fetchOpenAiPlatformModelIds() {
|
|
105
|
+
const key = process.env.OPENAI_API_KEY?.trim();
|
|
106
|
+
if (!key) {
|
|
107
|
+
throw new Error("OPENAI_API_KEY is not set.");
|
|
108
|
+
}
|
|
109
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
110
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
111
|
+
});
|
|
112
|
+
const text = await res.text();
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new Error(`OpenAI models HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
115
|
+
}
|
|
116
|
+
const j = JSON.parse(text);
|
|
117
|
+
const raw = (j.data ?? []).map((x) => x.id).filter((x) => typeof x === "string");
|
|
118
|
+
const filtered = raw.filter(isReasonableOpenAiChatModel);
|
|
119
|
+
const sorted = [...new Set(filtered)].sort((a, b) => {
|
|
120
|
+
const d = rankOpenAiPlanModel(b) - rankOpenAiPlanModel(a);
|
|
121
|
+
return d !== 0 ? d : a.localeCompare(b);
|
|
122
|
+
});
|
|
123
|
+
return sorted.slice(0, MAX_OPENAI_MODELS);
|
|
124
|
+
}
|
|
125
|
+
export async function fetchCodexOAuthModelIds() {
|
|
126
|
+
const { accessToken } = await resolveCodexOAuthAccessToken();
|
|
127
|
+
const clientVersion = await resolveCodexClientVersion();
|
|
128
|
+
const base = codexResponsesBaseUrl();
|
|
129
|
+
const url = new URL(`${base}/models`);
|
|
130
|
+
url.searchParams.set("client_version", clientVersion);
|
|
131
|
+
const res = await fetch(url.toString(), {
|
|
132
|
+
method: "GET",
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${accessToken}`,
|
|
135
|
+
Accept: "application/json",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new Error(`Codex models HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
141
|
+
}
|
|
142
|
+
let j;
|
|
143
|
+
try {
|
|
144
|
+
j = JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new Error("Codex models response was not JSON.");
|
|
148
|
+
}
|
|
149
|
+
const ids = extractModelIdsFromUnknownJson(j);
|
|
150
|
+
if (ids.length === 0) {
|
|
151
|
+
throw new Error("Codex models response contained no model ids.");
|
|
152
|
+
}
|
|
153
|
+
return ids.slice(0, MAX_CODEX_MODELS);
|
|
154
|
+
}
|
|
155
|
+
/** Parses `agent models` / `agent --list-models` human-readable listing. */
|
|
156
|
+
export function parseAgentModelsStdout(text) {
|
|
157
|
+
const rows = [];
|
|
158
|
+
for (const line of text.split("\n")) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
const m = /^([a-zA-Z0-9._-]+)\s+-\s+(.+)$/.exec(trimmed);
|
|
161
|
+
if (!m)
|
|
162
|
+
continue;
|
|
163
|
+
const id = m[1];
|
|
164
|
+
const rest = m[2].trim();
|
|
165
|
+
if (id === "Available")
|
|
166
|
+
continue;
|
|
167
|
+
rows.push({ id, label: `${id} — ${rest}` });
|
|
168
|
+
}
|
|
169
|
+
return rows;
|
|
170
|
+
}
|
|
171
|
+
export async function fetchCursorAgentModelRows(cwd) {
|
|
172
|
+
const command = agentBin();
|
|
173
|
+
const { stdout, stderr } = await execFileAsync(command, ["models"], {
|
|
174
|
+
cwd,
|
|
175
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
176
|
+
env: process.env,
|
|
177
|
+
});
|
|
178
|
+
const rows = parseAgentModelsStdout(`${stdout}\n${stderr}`);
|
|
179
|
+
if (rows.length === 0) {
|
|
180
|
+
throw new Error(`${command} models produced no parseable lines.`);
|
|
181
|
+
}
|
|
182
|
+
return rows;
|
|
183
|
+
}
|
|
184
|
+
async function pickFromLabels(rows, message) {
|
|
185
|
+
const cap = MAX_SELECT_CHOICES - 1;
|
|
186
|
+
const slice = rows.slice(0, cap);
|
|
187
|
+
if (rows.length > cap) {
|
|
188
|
+
log.info(dim(`Showing first ${cap} of ${rows.length} models.`));
|
|
189
|
+
}
|
|
190
|
+
const choices = [...slice.map((r) => r.label), DEFAULT_PICK_LABEL];
|
|
191
|
+
const picked = await askSelect({
|
|
192
|
+
message,
|
|
193
|
+
nonInteractive: false,
|
|
194
|
+
choices,
|
|
195
|
+
default: slice[0]?.label ?? DEFAULT_PICK_LABEL,
|
|
196
|
+
});
|
|
197
|
+
if (picked === DEFAULT_PICK_LABEL)
|
|
198
|
+
return undefined;
|
|
199
|
+
const hit = slice.find((r) => r.label === picked);
|
|
200
|
+
return hit?.value;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* After the user picks a provider (and auth exists), query the provider for
|
|
204
|
+
* model ids and ask which one to use. Returns `undefined` to keep env / built-in defaults.
|
|
205
|
+
*/
|
|
206
|
+
export async function pickPlanExecutionModel(params) {
|
|
207
|
+
if (!params.interactive)
|
|
208
|
+
return undefined;
|
|
209
|
+
log.blank();
|
|
210
|
+
log.step("Fetching available models from the provider…");
|
|
211
|
+
try {
|
|
212
|
+
if (params.provider === "openai") {
|
|
213
|
+
const ids = await fetchOpenAiPlatformModelIds();
|
|
214
|
+
const rows = ids.map((id) => ({ value: id, label: id }));
|
|
215
|
+
return await pickFromLabels(rows, "Choose OpenAI model for planning (↑/↓ · Enter)");
|
|
216
|
+
}
|
|
217
|
+
if (params.provider === "codex-oauth") {
|
|
218
|
+
const ids = await fetchCodexOAuthModelIds();
|
|
219
|
+
const rows = ids.map((id) => ({ value: id, label: id }));
|
|
220
|
+
return await pickFromLabels(rows, "Choose Codex (ChatGPT) model for planning (↑/↓ · Enter)");
|
|
221
|
+
}
|
|
222
|
+
const rows = await fetchCursorAgentModelRows(params.cwd);
|
|
223
|
+
return await pickFromLabels(rows.map((r) => ({ value: r.id, label: r.label })), "Choose Cursor Agent model for planning (↑/↓ · Enter)");
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
227
|
+
log.warn(`${yellow("!")} Model list could not be loaded: ${msg}`);
|
|
228
|
+
log.info(dim("Enter a model id manually, or press Enter for the environment / built-in default."));
|
|
229
|
+
const typed = await askInput({
|
|
230
|
+
message: "Model id (optional)",
|
|
231
|
+
nonInteractive: false,
|
|
232
|
+
default: "",
|
|
233
|
+
});
|
|
234
|
+
const t = typed.trim();
|
|
235
|
+
return t.length > 0 ? t : undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|