@giovannijecha/jecode 0.2.0 → 0.2.2

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 CHANGED
@@ -255,9 +255,10 @@ untrusted data.
255
255
  - Dangerous tools ask by default unless explicitly allowed for the session in
256
256
  **/permissions** or the process started with **--auto-approve**.
257
257
  - Credential fields are masked and excluded from transcripts. Approved shell
258
- commands receive no credential-like environment variables, and recognized
259
- credential values are redacted before tool output reaches the model, screen,
260
- history, or export.
258
+ commands receive no secret-bearing environment variables; `SSH_AUTH_SOCK`
259
+ is preserved so Git and SSH can use the user's agent, which means an approved
260
+ command can request that agent to authenticate. Recognized credential values
261
+ are redacted before tool output reaches the model, screen, history, or export.
261
262
  - ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
262
263
  flow. Refresh-token rotation is serialized across Jecode processes; OAuth
263
264
  tokens are withheld and redacted like API keys.
@@ -14,6 +14,7 @@ export const MAX_TOOL_CALLS_PER_STEP = 32;
14
14
  export async function runTurn(history, options, events, signal) {
15
15
  const specs = toolSpecs(options.tools);
16
16
  for (let step = 0; step < options.maxSteps; step++) {
17
+ throwIfAborted(signal);
17
18
  events.onStep?.(step + 1, options.maxSteps);
18
19
  // The message is displayed as it streams; what comes back here is the
19
20
  // assembled version, which exists to be appended to the history.
@@ -28,6 +29,7 @@ export async function runTurn(history, options, events, signal) {
28
29
  onStream: (event) => events.onStream(event),
29
30
  onStatus: (status) => events.onStatus?.(status),
30
31
  });
32
+ throwIfAborted(signal);
31
33
  const calls = assistant.content.filter(isToolCall);
32
34
  if (assistant.content.length === 0) {
33
35
  throw new Error(`${options.provider.id} completed without an answer or tool call`);
@@ -36,22 +38,58 @@ export async function runTurn(history, options, events, signal) {
36
38
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
37
39
  }
38
40
  history.push(assistant);
39
- if (assistant.usage !== undefined)
40
- events.onUsage?.(assistant.usage);
41
- if (calls.length === 0)
41
+ if (calls.length === 0) {
42
+ if (assistant.usage !== undefined)
43
+ events.onUsage?.(assistant.usage);
42
44
  return; // the model is done — hand back to the user
45
+ }
43
46
  // Calls run one after another because approval prompts serialise anyway,
44
47
  // but every result from this step goes back in a SINGLE message. Splitting
45
48
  // them teaches the model to stop batching its calls.
46
49
  const results = [];
47
- for (let index = 0; index < calls.length; index++) {
48
- const call = calls[index];
49
- events.onToolProgress?.(index + 1, calls.length);
50
- const preview = await look(call, options);
51
- events.onToolCall(call, preview);
52
- const { result, summary } = await settle(call, options, events, signal, preview);
53
- events.onToolResult(call, result, summary);
54
- results.push(result);
50
+ const announced = new Set();
51
+ try {
52
+ if (assistant.usage !== undefined)
53
+ events.onUsage?.(assistant.usage);
54
+ for (let index = 0; index < calls.length; index++) {
55
+ throwIfAborted(signal);
56
+ const call = calls[index];
57
+ events.onToolProgress?.(index + 1, calls.length);
58
+ const preview = await look(call, options, signal);
59
+ throwIfAborted(signal);
60
+ announced.add(call.id);
61
+ events.onToolCall(call, preview);
62
+ const { result, summary } = await settle(call, options, events, signal, preview);
63
+ results.push(result);
64
+ events.onToolResult(call, result, summary);
65
+ }
66
+ }
67
+ catch (error) {
68
+ const interrupted = signal?.aborted === true;
69
+ const repairs = [];
70
+ for (const call of calls.slice(results.length)) {
71
+ const run = interrupted
72
+ ? refuse(call, "interrupted before completion", "interrupted")
73
+ : refuse(call, "tool processing stopped before completion", "failed");
74
+ repairs.push({ call, run });
75
+ results.push(run.result);
76
+ }
77
+ history.push({ role: "user", content: results });
78
+ // History repair is the invariant. UI recovery is best-effort and must
79
+ // never replace the original exception or leave the conversation open.
80
+ for (const { call, run } of repairs) {
81
+ if (!announced.has(call.id))
82
+ continue;
83
+ try {
84
+ events.onToolResult(call, run.result, run.summary);
85
+ }
86
+ catch {
87
+ // The surface is already failing; the next turn can still proceed.
88
+ }
89
+ }
90
+ if (interrupted)
91
+ throw abortReason(signal);
92
+ throw error;
55
93
  }
56
94
  history.push({ role: "user", content: results });
57
95
  }
@@ -62,9 +100,13 @@ async function settle(call, options, events, signal, preview) {
62
100
  if (tool === undefined) {
63
101
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
64
102
  }
65
- if (tool.dangerous && !(await events.approve(call))) {
103
+ throwIfAborted(signal);
104
+ const approved = !tool.dangerous || await events.approve(call);
105
+ throwIfAborted(signal);
106
+ if (!approved) {
66
107
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
67
108
  }
109
+ throwIfAborted(signal);
68
110
  return runTool(tool, call, {
69
111
  ...options.toolContext,
70
112
  signal,
@@ -85,14 +127,21 @@ function refuse(call, reason, summary) {
85
127
  * file, a match that is not there — is simply no preview. Nothing about the
86
128
  * turn depends on it, because it exists for the user, not for the model.
87
129
  */
88
- async function look(call, options) {
130
+ async function look(call, options, signal) {
89
131
  const tool = findTool(options.tools, call.name);
90
132
  if (tool?.preview === undefined)
91
133
  return undefined;
92
134
  try {
93
- return await tool.preview(call.input, options.toolContext);
135
+ return await tool.preview(call.input, { ...options.toolContext, signal });
94
136
  }
95
137
  catch {
96
138
  return undefined;
97
139
  }
98
140
  }
141
+ function throwIfAborted(signal) {
142
+ if (signal?.aborted === true)
143
+ throw abortReason(signal);
144
+ }
145
+ function abortReason(signal) {
146
+ return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
147
+ }
@@ -2,8 +2,20 @@
2
2
  import { credentialValues } from "./credentials.js";
3
3
  import { accountValues } from "./accounts.js";
4
4
  const REDACTED = "[credential redacted]";
5
+ const MIN_HEURISTIC_SECRET_CHARS = 8;
6
+ const EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES = new Set([
7
+ "ANTHROPIC_API_KEY",
8
+ "OLLAMA_API_KEY",
9
+ "OPENAI_API_KEY",
10
+ ]);
5
11
  const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
6
12
  const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
13
+ const SAFE_ENVIRONMENT_NAMES = new Set([
14
+ "SSH_AUTH_SOCK",
15
+ "PWD",
16
+ "OLDPWD",
17
+ "PASSWORD_STORE_DIR",
18
+ ]);
7
19
  /** Preserve ordinary tool configuration while withholding credential-like values. */
8
20
  export function shellEnvironment(source = process.env) {
9
21
  const environment = {};
@@ -57,14 +69,20 @@ function sensitiveEnvironmentName(name) {
57
69
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
58
70
  .replace(/[^a-z0-9]+/gi, "_")
59
71
  .replace(/^_+|_+$/g, "");
72
+ if (SAFE_ENVIRONMENT_NAMES.has(normalized.toUpperCase()))
73
+ return false;
60
74
  return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
61
75
  COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
62
76
  }
63
77
  function secrets(source) {
64
78
  const values = new Set([...credentialValues(), ...accountValues()]);
65
79
  for (const [name, value] of Object.entries(source)) {
66
- if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
80
+ if (value === undefined || value === "")
81
+ continue;
82
+ const explicit = EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES.has(name.toUpperCase());
83
+ if (explicit || (value.length >= MIN_HEURISTIC_SECRET_CHARS && sensitiveEnvironment(name, value))) {
67
84
  values.add(value);
85
+ }
68
86
  }
69
87
  return [...values].filter((value) => value !== "").sort((left, right) => right.length - left.length);
70
88
  }
package/dist/effort.js ADDED
@@ -0,0 +1,25 @@
1
+ // One ordered vocabulary for reasoning depth. Providers expose the subset a
2
+ // selected model accepts; this module only validates and reconciles that data.
3
+ export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4
+ export function isEffort(value) {
5
+ return typeof value === "string" && EFFORTS.includes(value);
6
+ }
7
+ export function requireSupportedEffort(model, requested, supported) {
8
+ if (supported.includes(requested))
9
+ return requested;
10
+ const choices = supported.length === 0 ? "none" : supported.join(", ");
11
+ throw new Error(`${model} does not support effort "${requested}" (available: ${choices})`);
12
+ }
13
+ /** Keep the requested depth when possible, otherwise choose the nearest lower level. */
14
+ export function compatibleEffort(requested, supported) {
15
+ if (supported.length === 0)
16
+ return undefined;
17
+ if (supported.includes(requested))
18
+ return requested;
19
+ const requestedIndex = EFFORTS.indexOf(requested);
20
+ const candidates = EFFORTS.filter((value) => supported.includes(value));
21
+ if (requestedIndex < 0)
22
+ return candidates[0];
23
+ return [...candidates].reverse().find((value) => EFFORTS.indexOf(value) < requestedIndex)
24
+ ?? candidates[0];
25
+ }
@@ -43,6 +43,8 @@ export async function openAIAuthorization(forceToken, signal, onStatus) {
43
43
  async function refreshAccount(forceToken, signal, onStatus) {
44
44
  if (refreshing !== undefined)
45
45
  return abortable(refreshing, signal);
46
+ if (signal?.aborted === true)
47
+ throw abortReason(signal);
46
48
  onStatus?.("Refreshing ChatGPT sign-in");
47
49
  const task = updateOpenAICodexAccount(async (current) => {
48
50
  if (current === undefined)
@@ -51,20 +53,21 @@ async function refreshAccount(forceToken, signal, onStatus) {
51
53
  return current;
52
54
  if (forceToken === undefined && !expiresSoon(current))
53
55
  return current;
54
- return refreshOpenAITokens(current, signal);
55
- }, signal).then((account) => {
56
+ return refreshOpenAITokens(current);
57
+ }).then((account) => {
56
58
  if (account === undefined)
57
59
  throw new Error("ChatGPT account is not connected");
58
60
  return account;
59
61
  });
60
62
  refreshing = task;
61
- try {
62
- return await abortable(task, signal);
63
- }
64
- finally {
63
+ void task.then(() => {
65
64
  if (refreshing === task)
66
65
  refreshing = undefined;
67
- }
66
+ }, () => {
67
+ if (refreshing === task)
68
+ refreshing = undefined;
69
+ });
70
+ return abortable(task, signal);
68
71
  }
69
72
  function expiresSoon(account) {
70
73
  return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
@@ -5,6 +5,7 @@ import { readSettings } from "./settings.js";
5
5
  import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
6
  import { providerFailure } from "./provider-errors.js";
7
7
  import { providerLabel } from "./provider-label.js";
8
+ import { compatibleEffort } from "./effort.js";
8
9
  /**
9
10
  * The provider menu.
10
11
  *
@@ -61,6 +62,7 @@ export async function providersCommand(session, host, behavior = {}) {
61
62
  model: session.model,
62
63
  providerId: session.config.providerId,
63
64
  configModel: session.config.model,
65
+ effort: session.config.effort,
64
66
  };
65
67
  session.provider = chosen;
66
68
  // The model belonged to the old provider. Carrying it across would send
@@ -68,23 +70,31 @@ export async function providersCommand(session, host, behavior = {}) {
68
70
  session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
69
71
  session.config.providerId = chosen.id;
70
72
  session.config.model = session.model;
71
- if (session.model === "" && !(await modelsCommand(session, host, { announce: false, save: false }))) {
72
- session.provider = before.provider;
73
- session.model = before.model;
74
- session.config.providerId = before.providerId;
75
- session.config.model = before.configModel;
76
- return false;
73
+ if (session.model === "") {
74
+ if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
75
+ restoreProvider(session, before);
76
+ return false;
77
+ }
78
+ }
79
+ else {
80
+ const alignment = await alignEffort(session, host);
81
+ if (!alignment.ok) {
82
+ restoreProvider(session, before);
83
+ return false;
84
+ }
77
85
  }
78
86
  if (behavior.save !== false) {
79
87
  const saved = readSettings();
80
88
  const models = { ...saved.models };
81
89
  if (session.model !== "")
82
90
  models[chosen.id] = session.model;
83
- if (!(await saveDefaults(host, { provider: chosen.id, models }))) {
84
- session.provider = before.provider;
85
- session.model = before.model;
86
- session.config.providerId = before.providerId;
87
- session.config.model = before.configModel;
91
+ const patch = {
92
+ provider: chosen.id,
93
+ models,
94
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
95
+ };
96
+ if (!(await saveDefaults(host, patch))) {
97
+ restoreProvider(session, before);
88
98
  return false;
89
99
  }
90
100
  }
@@ -152,15 +162,31 @@ export async function modelsCommand(session, host, behavior = {}) {
152
162
  const chosen = ids[index];
153
163
  if (chosen === undefined)
154
164
  return false;
155
- const before = { model: session.model, configModel: session.config.model };
165
+ const before = {
166
+ model: session.model,
167
+ configModel: session.config.model,
168
+ effort: session.config.effort,
169
+ };
156
170
  session.model = chosen;
157
171
  session.config.model = chosen;
172
+ const alignment = await alignEffort(session, host);
173
+ if (!alignment.ok) {
174
+ session.model = before.model;
175
+ session.config.model = before.configModel;
176
+ session.config.effort = before.effort;
177
+ return false;
178
+ }
158
179
  if (behavior.save !== false) {
159
180
  const saved = readSettings();
160
181
  const models = { ...saved.models, [provider.id]: chosen };
161
- if (!(await saveDefaults(host, { models }))) {
182
+ const patch = {
183
+ models,
184
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
185
+ };
186
+ if (!(await saveDefaults(host, patch))) {
162
187
  session.model = before.model;
163
188
  session.config.model = before.configModel;
189
+ session.config.effort = before.effort;
164
190
  return false;
165
191
  }
166
192
  }
@@ -169,7 +195,34 @@ export async function modelsCommand(session, host, behavior = {}) {
169
195
  }
170
196
  return true;
171
197
  }
172
- /** The way to put a menu up, or nothing — and the reason, already said. */
198
+ function restoreProvider(session, before) {
199
+ session.provider = before.provider;
200
+ session.model = before.model;
201
+ session.config.providerId = before.providerId;
202
+ session.config.model = before.configModel;
203
+ session.config.effort = before.effort;
204
+ }
205
+ async function alignEffort(session, host) {
206
+ if (session.provider.efforts === undefined)
207
+ return { ok: true };
208
+ let supported;
209
+ try {
210
+ supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
211
+ }
212
+ catch (error) {
213
+ host.emit({
214
+ kind: "notice",
215
+ text: providerFailure(session.provider, error, true),
216
+ tone: "error",
217
+ });
218
+ return { ok: false };
219
+ }
220
+ const adjusted = compatibleEffort(session.config.effort, supported);
221
+ if (adjusted === undefined || adjusted === session.config.effort)
222
+ return { ok: true };
223
+ session.config.effort = adjusted;
224
+ return { ok: true, adjusted };
225
+ }
173
226
  function chooser(host) {
174
227
  if (host.choose === undefined) {
175
228
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
@@ -13,7 +13,8 @@ export async function assembleAnthropic(events, onStream) {
13
13
  let stopReason;
14
14
  let stopDetails;
15
15
  let usage;
16
- for await (const raw of events) {
16
+ let complete = false;
17
+ stream: for await (const raw of events) {
17
18
  const event = raw;
18
19
  switch (event.type) {
19
20
  case "message_start":
@@ -51,10 +52,15 @@ export async function assembleAnthropic(events, onStream) {
51
52
  case "error": {
52
53
  throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
53
54
  }
55
+ case "message_stop":
56
+ complete = true;
57
+ break stream;
54
58
  default:
55
- break; // message_start, ping, message_stop: nothing to accumulate
59
+ break; // ping and unknown forward-compatible events
56
60
  }
57
61
  }
62
+ if (!complete)
63
+ throw new Error("anthropic stream ended before message_stop");
58
64
  const content = [...blocks.entries()]
59
65
  .sort(([a], [b]) => a - b)
60
66
  .map(([, block]) => block);
@@ -9,19 +9,27 @@
9
9
  import { postSse } from "./http.js";
10
10
  import { listModels } from "./catalog.js";
11
11
  import { keyFor } from "../credentials.js";
12
+ import { EFFORTS, requireSupportedEffort } from "../effort.js";
12
13
  import { assembleAnthropic } from "./anthropic-stream.js";
13
14
  import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
14
15
  const ENDPOINT = "https://api.anthropic.com/v1/messages";
15
16
  const MODELS = "https://api.anthropic.com/v1/models?limit=100";
16
17
  const API_VERSION = "2023-06-01";
17
18
  const KEY = "ANTHROPIC_API_KEY";
18
- // Adaptive thinking and `output_config.effort` exist on the 4.6-and-later
19
- // families only. Older models reject both with a 400, so `/model claude-haiku-4-5`
20
- // would fail on send if the request shape were fixed. It follows the model.
21
- const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
19
+ const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
20
+ const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
21
+ const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
22
22
  export function supportsAdaptiveThinking(model) {
23
23
  return ADAPTIVE.test(model);
24
24
  }
25
+ export function anthropicEfforts(model) {
26
+ if (/^claude-(?:(?:opus|sonnet)-4-6|mythos-preview)(?:-|$)/.test(model)) {
27
+ return MAX_WITHOUT_XHIGH;
28
+ }
29
+ if (/^claude-opus-4-5(?:-|$)/.test(model))
30
+ return ANTHROPIC_45_EFFORTS;
31
+ return supportsAdaptiveThinking(model) ? EFFORTS : [];
32
+ }
25
33
  export const anthropic = {
26
34
  id: "anthropic",
27
35
  // Sonnet is the default because it is the one that can be left running.
@@ -36,6 +44,9 @@ export const anthropic = {
36
44
  models(signal, onStatus) {
37
45
  return listModels(MODELS, headers(requireKey()), signal, onStatus);
38
46
  },
47
+ async efforts(model) {
48
+ return anthropicEfforts(model);
49
+ },
39
50
  location: () => "cloud",
40
51
  async send(req) {
41
52
  const key = requireKey();
@@ -49,7 +60,12 @@ export const anthropic = {
49
60
  };
50
61
  if (supportsAdaptiveThinking(req.model)) {
51
62
  body["thinking"] = { type: "adaptive", display: "summarized" };
52
- body["output_config"] = { effort: req.effort };
63
+ }
64
+ const efforts = anthropicEfforts(req.model);
65
+ if (efforts.length > 0) {
66
+ body["output_config"] = {
67
+ effort: requireSupportedEffort(req.model, req.effort, efforts),
68
+ };
53
69
  }
54
70
  const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
55
71
  const data = await assembleAnthropic(events, req.onStream);
@@ -51,6 +51,9 @@ export async function assembleOllama(events, onStream) {
51
51
  calls.set(index, call);
52
52
  }
53
53
  }
54
+ if (finishReason === undefined) {
55
+ throw new Error("ollama stream ended before a finish reason");
56
+ }
54
57
  const toolCalls = [...calls.entries()]
55
58
  .sort(([a], [b]) => a - b)
56
59
  // Not every server sends an id, and the loop needs one to pair the result
@@ -45,6 +45,9 @@ export const ollama = {
45
45
  const at = endpoint();
46
46
  return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
47
47
  },
48
+ async efforts() {
49
+ return [];
50
+ },
48
51
  location: () => {
49
52
  try {
50
53
  return endpoint().loopback ? "local" : "cloud";
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { openAICodexAccount } from "../accounts.js";
4
4
  import { openAIAuthorization } from "../openai-account.js";
5
5
  import { applicationVersion } from "../version.js";
6
+ import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
6
7
  import { getJson, postSse } from "./http.js";
7
8
  import { assembleOpenAI } from "./openai-stream.js";
8
9
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
@@ -16,6 +17,8 @@ const SESSION_ID = randomUUID();
16
17
  const MAX_CATALOG_ITEMS = 4_000;
17
18
  const MAX_MODELS = 1_000;
18
19
  const MAX_MODEL_CHARS = 256;
20
+ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
21
+ let effortByModel = new Map();
19
22
  export const openaiCodex = {
20
23
  id: ID,
21
24
  defaultModel: "",
@@ -25,12 +28,21 @@ export const openaiCodex = {
25
28
  },
26
29
  location: () => "cloud",
27
30
  async models(signal, onStatus) {
28
- return withAuthorization(async (authorization) => {
29
- const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
30
- return modelIds(body);
31
- }, signal, onStatus);
31
+ const catalog = await loadCatalog(signal, onStatus);
32
+ effortByModel = catalog.efforts;
33
+ return catalog.ids;
34
+ },
35
+ async efforts(model, signal, onStatus) {
36
+ const cached = effortByModel.get(model);
37
+ if (cached !== undefined)
38
+ return cached;
39
+ const catalog = await loadCatalog(signal, onStatus);
40
+ effortByModel = catalog.efforts;
41
+ return effortByModel.get(model) ?? fallbackEfforts(model);
32
42
  },
33
43
  async send(req) {
44
+ const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
45
+ const effort = requireSupportedEffort(req.model, req.effort, efforts);
34
46
  return withAuthorization(async (authorization) => {
35
47
  const events = await postSse(`${BASE}/responses`, {
36
48
  ...headers(authorization, randomUUID()),
@@ -44,7 +56,7 @@ export const openaiCodex = {
44
56
  tools: req.tools.map(toWireTool),
45
57
  tool_choice: "auto",
46
58
  parallel_tool_calls: true,
47
- reasoning: { effort: req.effort, summary: "auto" },
59
+ reasoning: { effort, summary: "auto" },
48
60
  text: { verbosity: "low" },
49
61
  include: ["reasoning.encrypted_content"],
50
62
  prompt_cache_key: SESSION_ID,
@@ -57,6 +69,12 @@ export const openaiCodex = {
57
69
  }, req.signal, req.onStatus);
58
70
  },
59
71
  };
72
+ async function loadCatalog(signal, onStatus) {
73
+ return withAuthorization(async (authorization) => {
74
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
75
+ return modelCatalog(body);
76
+ }, signal, onStatus);
77
+ }
60
78
  async function withAuthorization(operation, signal, onStatus) {
61
79
  let authorization = await openAIAuthorization(undefined, signal, onStatus);
62
80
  try {
@@ -80,12 +98,12 @@ function headers(authorization, requestId) {
80
98
  "x-client-request-id": requestId,
81
99
  };
82
100
  }
83
- function modelIds(value) {
101
+ function modelCatalog(value) {
84
102
  const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
85
103
  if (source === undefined)
86
104
  throw new Error("OpenAI Codex did not return a model list");
87
105
  const seen = new Set();
88
- return source
106
+ const models = source
89
107
  .slice(0, MAX_CATALOG_ITEMS)
90
108
  .flatMap((entry) => {
91
109
  if (!record(entry))
@@ -98,11 +116,36 @@ function modelIds(value) {
98
116
  seen.has(id))
99
117
  return [];
100
118
  seen.add(id);
101
- return [{ id, priority: typeof entry["priority"] === "number" ? entry["priority"] : 0 }];
119
+ return [{
120
+ id,
121
+ priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
122
+ efforts: reasoningLevels(entry, id),
123
+ }];
102
124
  })
103
125
  .sort((left, right) => left.priority - right.priority)
104
- .slice(0, MAX_MODELS)
105
- .map((entry) => entry.id);
126
+ .slice(0, MAX_MODELS);
127
+ return {
128
+ ids: models.map((entry) => entry.id),
129
+ efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
130
+ };
131
+ }
132
+ function reasoningLevels(entry, model) {
133
+ const source = entry["supported_reasoning_levels"];
134
+ if (!Array.isArray(source))
135
+ return fallbackEfforts(model);
136
+ const seen = new Set();
137
+ const efforts = source.flatMap((level) => {
138
+ if (!record(level) || !isEffort(level["effort"]) || seen.has(level["effort"]))
139
+ return [];
140
+ seen.add(level["effort"]);
141
+ return [level["effort"]];
142
+ });
143
+ return efforts.length === 0 ? fallbackEfforts(model) : efforts;
144
+ }
145
+ function fallbackEfforts(model) {
146
+ if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
147
+ return EFFORTS;
148
+ return XHIGH_EFFORTS;
106
149
  }
107
150
  function statusOf(error) {
108
151
  return typeof error === "object" && error !== null && "status" in error
@@ -42,7 +42,7 @@ export async function assembleOpenAI(events, onStream) {
42
42
  break;
43
43
  }
44
44
  }
45
- return { output: items };
45
+ throw new Error("openai stream ended before a terminal response event");
46
46
  }
47
47
  function reconcileOutput(completed, items) {
48
48
  if (completed === undefined)
@@ -1,11 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
- // The Responses API has no `xhigh`; collapse it onto the nearest level rather
5
- // than passing through a value it will reject.
6
- export function normalizeEffort(effort) {
7
- return effort === "xhigh" || effort === "max" ? "high" : effort;
8
- }
9
4
  export function toWireTool(tool) {
10
5
  return {
11
6
  type: "function",