@jskit-ai/assistant-core 0.2.19 → 0.2.21

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 CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A delivered Codex turn is held as not yet started only until Codex reports it: a `turn/started` that arrives before the `turn/start` answer counts, and a thread read listing exactly that turn as completed or failed settles a turn whose notifications were missed, so its answer is no longer kept waiting for a notification that never comes.
5
6
  - The API driver's state note, which carries the current time, follows the conversation as its last message instead of ending the system prompt, so the provider's prompt cache keeps the whole history from one turn to the next instead of charging it uncached on each turn's first model call (`@jskit-ai/agent` `runLoop`).
6
7
  - Assistant failures that reach a person translate: 63 more undotted codes render from `jskit.errors.<code>`, and 98 sentences that share a code with others, or are sent without one, carry a `messageCode` (`assistant.claude.*`, `assistant.codex.*`, `assistant.opencode.*`, `assistant.conversation.*`, `assistant.turn.*`). The English messages are unchanged.
7
8
  - The OpenCode version mismatch, Claude control-request timeout and unreadable saved Claude conversation errors carry `params` for their catalog English.
8
9
  - The assistant settings resource sends its system prompt field messages in the coded `{ code, message, params }` form.
9
10
 
10
- ### New message keys (163)
11
+ - Custom model endpoints: `customAiProvider({ engineId, baseUrl, model, contextWindow })` (with `CUSTOM_AI_PROVIDER_ID`, `CUSTOM_AI_MINIMUM_CONTEXT`, `CUSTOM_AI_PLACEHOLDER_TOKEN` and `connectionAiProvider`) in `shared/native-providers` describes a model server the host's user runs, such as Ollama, LM Studio, `llama-server` or vLLM, in the native provider shape, so Codex, Claude Code and the Colleague drivers connect to it. Codex gets no bearer token without a key, no freeform `apply_patch` and no parallel tool calls; Claude Code always gets a token (the key, or `local`) so a saved claude.ai login never reaches the endpoint, its model in every slot and an output limit of a quarter of the context. The Codex history adapter routes `custom~<base64url(baseUrl)>` to plain `http(s)` upstreams only and ends a turn whose server is down with `assistant_custom_endpoint_unreachable`. `readCodexCustomModels(codexHome)` reads a custom Codex home's catalogue.
12
+ - `server/custom-endpoint` adds `checkCustomEndpoint` (one bounded request on the engine's own API, up to 2 minutes, then Ollama's loaded context from `/api/ps`, refusing one smaller than entered), `verifyCustomEndpoint`, `readOllamaLoadedContext` and `probeCustomEndpoint` (one model-list read for health). Errors carry `custom_endpoint_*` codes and say what to fix.
13
+
14
+ ### New message keys (164)
11
15
 
12
16
  <details><summary>Key list</summary>
13
17
 
@@ -174,10 +178,11 @@
174
178
  - `jskit.errors.assistant_tool_output_invalid`
175
179
  - `jskit.errors.assistant_tool_unknown`
176
180
  - `jskit.errors.assistant_claude_control_timeout`
181
+ - `jskit.errors.assistant_custom_endpoint_unreachable`
177
182
 
178
183
  </details>
179
184
 
180
- ### Codes (163)
185
+ ### Codes (164)
181
186
 
182
187
  Error, field-error and message codes this package now sends, with their English. Undotted codes kept their existing values and render from `jskit.errors.<code>`.
183
188
 
@@ -346,6 +351,7 @@ Error, field-error and message codes this package now sends, with their English.
346
351
  | `assistant_tool_output_invalid` | Assistant tool output validation failed. |
347
352
  | `assistant_tool_unknown` | Unknown tool. |
348
353
  | `assistant_claude_control_timeout` | Claude {request} timed out. |
354
+ | `assistant_custom_endpoint_unreachable` | Your model server at {upstream} isn't answering. Start it, then send your message again. |
349
355
 
350
356
  ## 0.2.0 - 2026-10-29
351
357
 
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.19",
11
+ "@jskit-ai/assistant-core": "0.2.21",
12
12
  "@jskit-ai/http-runtime": "0.2.9",
13
13
  "@jskit-ai/kernel": "0.2.9",
14
14
  "@mdi/js": "^7.4.47",
package/locales/en.json CHANGED
@@ -530,6 +530,7 @@
530
530
  "assistant_codex_provider_switch_busy": "Stop the current turn before immediately changing model providers.",
531
531
  "assistant_codex_history_request_too_large": "Codex history exceeds the adapter request limit.",
532
532
  "assistant_codex_history_request_unreadable": "Codex sent an unreadable or oversized history request.",
533
+ "assistant_custom_endpoint_unreachable": "Your model server at {upstream} isn't answering. Start it, then send your message again.",
533
534
  "assistant_codex_app_server_message_too_large": "Codex app-server message exceeded the configured transport limit.",
534
535
  "codex_reconnect_required": "Sign in with codex login before continuing.",
535
536
  "codex_provider_connection_changed": "This Codex provider connection changed. Work is stopped; use Send or Resume after reconnecting.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -30,6 +30,7 @@
30
30
  "./server/opencode-turn": "./src/server/hosts/openCodeTurn.js",
31
31
  "./server/opencode-process": "./src/server/hosts/openCodeProcess.js",
32
32
  "./shared/native-providers": "./src/shared/nativeProviders.js",
33
+ "./server/custom-endpoint": "./src/server/hosts/customEndpoint.js",
33
34
  "./server/codex-provider": "./src/server/hosts/codexProvider.js",
34
35
  "./server/native-history": "./src/server/hosts/nativeHistory.js",
35
36
  "./testing/native-codex": "./src/testing/nativeCodex.js",
@@ -178,6 +179,10 @@
178
179
  "subpath": "./shared/native-providers",
179
180
  "summary": "Advanced native host: Exact native provider routes, engine configuration facts and qualified model capabilities; applications choose which models to offer."
180
181
  },
182
+ {
183
+ "subpath": "./server/custom-endpoint",
184
+ "summary": "Advanced native host: Checks of a model server the person runs: one connect-time request on the engine's own API, the context Ollama really loaded, and a model-list read for health."
185
+ },
181
186
  {
182
187
  "subpath": "./server/codex-provider",
183
188
  "summary": "Advanced native host: Original Codex provider acquisition, thread selection, control, recovery, native history retirement and private renewal identity operations."
@@ -1,5 +1,5 @@
1
1
  import { jsonHash } from "./normalize.js";
2
- import { nativeAiProvider } from "../../shared/nativeProviders.js";
2
+ import { CUSTOM_AI_PLACEHOLDER_TOKEN, CUSTOM_AI_PROVIDER_ID, connectionAiProvider, nativeAiProvider } from "../../shared/nativeProviders.js";
3
3
  import { Duplex } from "node:stream";
4
4
  import { stat } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
@@ -132,18 +132,24 @@ export function claudeConnectionIdentity(configRoot, providerId, baseUrl, apiKey
132
132
 
133
133
  /** Native provider routing; the application supplies only its authorized selection. */
134
134
  export function claudeModelConfiguration({ providerId, model: modelId }, connection) {
135
- const provider = nativeAiProvider(providerId);
135
+ const provider = connectionAiProvider(providerId, connection);
136
136
  const definition = provider?.models.find(({ id }) => id === modelId);
137
- if (provider && (!definition || !connection?.apiKey)) throw Object.assign(new Error("Choose a connected Claude Code model."), {
137
+ if ((provider || providerId === CUSTOM_AI_PROVIDER_ID) &&
138
+ (!definition || !(connection?.apiKey || provider.custom))) throw Object.assign(new Error("Choose a connected Claude Code model."), {
138
139
  messageCode: "assistant.claude.chooseConnectedModel", messageParams: {}
139
140
  });
140
- const model = definition?.contextWindow >= 1000000 ? `${definition.id}[1m]` : modelId;
141
+ const model = !provider?.custom && definition?.contextWindow >= 1000000 ? `${definition.id}[1m]` : modelId;
142
+ // A custom endpoint's own window: Claude Code assumes 200K and 32K output for
143
+ // a model it does not know, past what most model servers load.
144
+ const outputTokens = provider?.custom ? Math.min(32000, Math.floor(definition.contextWindow / 4)) : 0;
141
145
  const externalModel = provider ? model : "";
142
146
  // Reset every provider override when returning to the native subscription.
143
147
  // Background calls and subagents must stay on the selected provider too.
144
148
  const env = {
145
149
  ANTHROPIC_BASE_URL: connection?.baseUrl || "https://api.anthropic.com",
146
- ANTHROPIC_AUTH_TOKEN: connection?.apiKey || "", ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "",
150
+ // Never empty for a custom endpoint, so the saved claude.ai login is not sent there.
151
+ ANTHROPIC_AUTH_TOKEN: connection?.apiKey || (provider?.custom ? CUSTOM_AI_PLACEHOLDER_TOKEN : ""),
152
+ ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "",
147
153
  ANTHROPIC_MODEL: externalModel, ANTHROPIC_DEFAULT_MODEL: externalModel,
148
154
  ANTHROPIC_DEFAULT_OPUS_MODEL: externalModel,
149
155
  ANTHROPIC_DEFAULT_SONNET_MODEL: externalModel,
@@ -151,6 +157,7 @@ export function claudeModelConfiguration({ providerId, model: modelId }, connect
151
157
  ANTHROPIC_DEFAULT_HAIKU_MODEL: definition?.id || "",
152
158
  CLAUDE_CODE_SUBAGENT_MODEL: definition?.id || "",
153
159
  CLAUDE_CODE_AUTO_COMPACT_WINDOW: provider ? String(provider.claudeAutoCompactWindow) : "",
160
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: outputTokens ? String(outputTokens) : "",
154
161
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: provider ? "1" : "",
155
162
  CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1"
156
163
  };
@@ -244,7 +251,7 @@ export async function stopClaudeCodeProcess({ process: native, executionId, stop
244
251
 
245
252
  export async function bindClaudeConversationAccount(entry, { identity, providerId, allowAccountChange = false, stop, save }) {
246
253
  entry.accountIdentities ||= {};
247
- if (!nativeAiProvider(providerId) && entry.accountIdentities[providerId] && entry.accountIdentities[providerId] !== identity) {
254
+ if (!nativeAiProvider(providerId) && providerId !== CUSTOM_AI_PROVIDER_ID && entry.accountIdentities[providerId] && entry.accountIdentities[providerId] !== identity) {
248
255
  await stop(entry, "The signed-in Claude account changed.");
249
256
  if (!allowAccountChange) {
250
257
  throw Object.assign(new Error("This conversation belongs to another Claude account. Reconnect that account or start a new session."), {
@@ -320,7 +320,7 @@ export function codexProviderModelCatalog({ models }) {
320
320
  context_window: model.contextWindow,
321
321
  max_context_window: model.contextWindow,
322
322
  effective_context_window_percent: 95,
323
- supports_parallel_tool_calls: true,
323
+ supports_parallel_tool_calls: model.parallelToolCalls !== false,
324
324
  experimental_supported_tools: [],
325
325
  input_modalities: model.images ? ["text", "image"] : ["text"],
326
326
  prefer_websockets: false,
@@ -336,7 +336,9 @@ export function codexProviderConfiguration(provider, apiKey) {
336
336
  web_search: provider.webSearch ? "live" : "disabled",
337
337
  [`model_providers.${provider.id}`]: {
338
338
  name: provider.label || provider.id, base_url: provider.baseUrl, wire_api: "responses",
339
- requires_openai_auth: false, experimental_bearer_token: apiKey
339
+ requires_openai_auth: false,
340
+ // A custom endpoint may need no key; Codex then sends no Authorization header.
341
+ ...(apiKey ? { experimental_bearer_token: apiKey } : {})
340
342
  }
341
343
  };
342
344
  }
@@ -356,7 +358,7 @@ export function codexProviderFileConfiguration(provider, key, codexHome) {
356
358
  `model = ${JSON.stringify(provider.models[0].id)}`,
357
359
  `model_provider = ${JSON.stringify(provider.id)}`,
358
360
  codexProviderCatalogSetting(codexHome),
359
- `model_reasoning_effort = ${JSON.stringify(provider.models[0].defaultThinking)}`,
361
+ ...(provider.models[0].defaultThinking ? [`model_reasoning_effort = ${JSON.stringify(provider.models[0].defaultThinking)}`] : []),
360
362
  'model_reasoning_summary = "none"',
361
363
  `web_search = "${provider.webSearch ? "live" : "disabled"}"`,
362
364
  `[model_providers.${provider.id}]`,
@@ -364,11 +366,27 @@ export function codexProviderFileConfiguration(provider, key, codexHome) {
364
366
  `base_url = ${JSON.stringify(provider.baseUrl)}`,
365
367
  'wire_api = "responses"',
366
368
  'requires_openai_auth = false',
367
- `experimental_bearer_token = ${JSON.stringify(key)}`,
369
+ ...(key ? [`experimental_bearer_token = ${JSON.stringify(key)}`] : []),
368
370
  ""
369
371
  ].join("\n");
370
372
  }
371
373
 
374
+ /**
375
+ * The catalogue rows of a home codexProviderFileConfiguration wrote for a
376
+ * custom endpoint, whose model only its saved connection names; none for any
377
+ * other home.
378
+ */
379
+ export async function readCodexCustomModels(codexHome) {
380
+ const config = await readFile(path.join(codexHome, "config.toml"), "utf8").catch((error) => {
381
+ if (error.code === "ENOENT") return "";
382
+ throw error;
383
+ });
384
+ if (!/^model_provider = "custom"$/mu.test(config)) return [];
385
+ const catalogue = JSON.parse(await readFile(codexProviderCatalogPath(codexHome), "utf8"));
386
+ if (!Array.isArray(catalogue?.models)) throw new Error("Codex custom endpoint home is incomplete.");
387
+ return catalogue.models;
388
+ }
389
+
372
390
  /**
373
391
  * Copy a curated provider home's config and catalogue into another home, with
374
392
  * the copied config naming the copied catalogue. A config that does not name
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { Readable } from "node:stream";
7
7
  import { isDeepStrictEqual } from "node:util";
8
8
  import { gunzipSync, zstdDecompressSync } from "node:zlib";
9
- import { nativeAiModel, nativeAiProvider } from "../../shared/nativeProviders.js";
9
+ import { CUSTOM_AI_PROVIDER_ID, nativeAiModel, nativeAiProvider } from "../../shared/nativeProviders.js";
10
10
 
11
11
  const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
12
12
  const UPSTREAMS = Object.freeze({
@@ -19,6 +19,28 @@ const UPSTREAMS = Object.freeze({
19
19
  const HOP_HEADERS = ["connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
20
20
  "te", "trailer", "transfer-encoding", "upgrade"];
21
21
 
22
+ // A custom endpoint's route segment carries its own base URL, so any runtime can
23
+ // reach the endpoint a thread names; its single model is qualified for history
24
+ // recovery like the curated routes.
25
+ function historyRouted(destination) {
26
+ return Boolean(nativeAiProvider(destination) || destination === CUSTOM_AI_PROVIDER_ID);
27
+ }
28
+
29
+ function customRouteSegment(baseUrl) {
30
+ return `${CUSTOM_AI_PROVIDER_ID}~${Buffer.from(baseUrl.replace(/\/$/u, "")).toString("base64url")}`;
31
+ }
32
+
33
+ function customUpstream(encoded = "") {
34
+ let url;
35
+ try {
36
+ url = new URL(Buffer.from(encoded, "base64url").toString("utf8"));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash
41
+ ? url.href.replace(/\/$/u, "") : undefined;
42
+ }
43
+
22
44
  function unsupportedHistory(message) {
23
45
  return new AppError(422, message, { code: "assistant_codex_history_unsupported" });
24
46
  }
@@ -170,9 +192,10 @@ async function* readCodexHistoryRows(file, start, end, signal, { strictComplete
170
192
  // cannot read OpenAI's encrypted replacement. Recover that exact boundary as
171
193
  // historical data, only when it is actually present in a foreign request.
172
194
  async function restoreCompactedHistory(body, { destination, historyPath, codexHome, signal, maxRequestBytes }) {
173
- if (!nativeAiProvider(destination) || !Array.isArray(body?.input) ||
195
+ if (!historyRouted(destination) || !Array.isArray(body?.input) ||
174
196
  !body.input.some((item) => item?.type === "compaction")) return body;
175
- const model = nativeAiModel(body.model, destination);
197
+ const model = destination === CUSTOM_AI_PROVIDER_ID ? { modelProviderId: destination, codexHistoryRouting: true }
198
+ : nativeAiModel(body.model, destination);
176
199
  if (model?.modelProviderId !== destination || !model.codexHistoryRouting) {
177
200
  throw compactionHistoryError("this model has not been qualified for history recovery.");
178
201
  }
@@ -342,8 +365,8 @@ async function startCodexHistoryAdapter({ token, codexHome, readHistoryPath = re
342
365
  try {
343
366
  const url = new URL(request.url, "http://127.0.0.1");
344
367
  const parts = url.pathname.split("/");
345
- const destination = parts[3];
346
- const upstream = UPSTREAMS[destination];
368
+ const [destination, encodedUpstream] = (parts[3] || "").split("~", 2);
369
+ const upstream = destination === CUSTOM_AI_PROVIDER_ID ? customUpstream(encodedUpstream) : UPSTREAMS[destination];
347
370
  const hasHistory = parts[4] === "history";
348
371
  let historyPath = hasHistory ? Buffer.from(parts[5] || "", "base64url").toString("utf8") : "";
349
372
  const route = parts.slice(hasHistory ? 6 : 4).join("/");
@@ -370,7 +393,7 @@ async function startCodexHistoryAdapter({ token, codexHome, readHistoryPath = re
370
393
  } catch {
371
394
  throw new AppError(400, "Codex sent an unreadable or oversized history request.", { code: "assistant_codex_history_request_unreadable" });
372
395
  }
373
- if (!historyPath && nativeAiProvider(destination) && Array.isArray(body?.input) && body.input.some(item => item?.type === "compaction")) {
396
+ if (!historyPath && historyRouted(destination) && Array.isArray(body?.input) && body.input.some(item => item?.type === "compaction")) {
374
397
  // An exclusively owned conversation can resolve its current native
375
398
  // file, including compaction during its very first turn.
376
399
  historyPath = await readHistoryPath(abort.signal);
@@ -381,9 +404,17 @@ async function startCodexHistoryAdapter({ token, codexHome, readHistoryPath = re
381
404
  }
382
405
  const headers = forwardedHeaders(request.headers);
383
406
  headers.set("accept-encoding", "identity");
384
- const result = await fetchImpl(`${upstream}/${route}${url.search}`, {
385
- method: request.method, headers, body, signal: abort.signal, redirect: "error"
386
- });
407
+ let result;
408
+ try {
409
+ result = await fetchImpl(`${upstream}/${route}${url.search}`, {
410
+ method: request.method, headers, body, signal: abort.signal, redirect: "error"
411
+ });
412
+ } catch (error) {
413
+ // The owner's own server is down more often than a provider: say which and what to do.
414
+ if (destination !== CUSTOM_AI_PROVIDER_ID || abort.signal.aborted) throw error;
415
+ throw Object.assign(new AppError(502, `Your model server at ${upstream} isn't answering. Start it, then send your message again.`,
416
+ { code: "assistant_custom_endpoint_unreachable" }), { params: { upstream } });
417
+ }
387
418
  // DeepSeek labels this explicit context rejection as a generic invalid
388
419
  // request. Preserve the provider's details while exposing the native
389
420
  // recovery code; the adapter never retries the rejected inference.
@@ -446,13 +477,18 @@ async function prepareCodexHistory(params, client, { baseUrl, modelProviderId, .
446
477
  if (!baseUrl) return params;
447
478
  const modelProvider = params.modelProvider || modelProviderId || "openai";
448
479
  const curatedProvider = nativeAiProvider(modelProvider);
449
- if (curatedProvider) {
480
+ if (curatedProvider || modelProvider === CUSTOM_AI_PROVIDER_ID) {
450
481
  const configKey = `model_providers.${modelProvider}`;
451
482
  const config = params.config?.[configKey];
452
483
  // Only the curated route owns these credentials. Do not redirect an
453
- // unrelated custom provider to our fixed upstream.
454
- const providerUrl = `${baseUrl}/${modelProvider}`;
455
- if (config?.base_url !== curatedProvider.baseUrl &&
484
+ // unrelated provider to our fixed upstream. The custom endpoint's route
485
+ // names the base URL its thread configuration gave.
486
+ if (!curatedProvider && typeof config?.base_url !== "string") return params;
487
+ const customPrefix = `${baseUrl}/${CUSTOM_AI_PROVIDER_ID}~`;
488
+ const providerUrl = curatedProvider ? `${baseUrl}/${modelProvider}`
489
+ : config.base_url.startsWith(customPrefix) ? `${baseUrl}/${config.base_url.slice(baseUrl.length + 1).split("/")[0]}`
490
+ : `${baseUrl}/${customRouteSegment(config.base_url)}`;
491
+ if (curatedProvider && config?.base_url !== curatedProvider.baseUrl &&
456
492
  config?.base_url !== providerUrl && !config?.base_url?.startsWith(`${providerUrl}/history/`)) return params;
457
493
  let historyPath = options.historyPath;
458
494
  if (!historyPath && options.threadId) {
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerTurnKey, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
2
2
  import { codexAppServerProviderConnectionGeneration } from "./codexModelCatalog.js";
3
3
  import {
4
4
  codexAppServerAssistantItemText,
@@ -239,8 +239,8 @@ export function createCodexNotificationObserver({
239
239
  threadId: normalizedThreadId,
240
240
  turnId: codexAppServerNotificationTurnId(notification)
241
241
  };
242
- // Anything Codex reports about a turn means it has started that turn.
243
- journal.unstartedTurns.delete(codexAppServerTurnKey(normalizedThreadId, notificationContext.turnId));
242
+ // Anything Codex reports about a turn means it has started that turn, even before its answer to turn/start.
243
+ journal.noteTurnReported(normalizedThreadId, notificationContext.turnId);
244
244
  if (method === "thread/tokenUsage/updated") {
245
245
  notificationQueue.run(notificationContext, async () => {
246
246
  const store = await createStore(normalizedSessionId);
@@ -7,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { createConversationProcessIdentity } from "./processIdentity.js";
8
8
  import { isRecord, normalizePositiveInteger } from "@jskit-ai/kernel/shared/support/normalize";
9
9
  import { normalizeNullableText } from "./normalize.js";
10
- import { prepareCodexModelCatalog } from "./codexConfiguration.js";
10
+ import { prepareCodexModelCatalog, readCodexCustomModels } from "./codexConfiguration.js";
11
11
  import { startCodexHistoryAdapter } from "./codexHistoryAdapter.js";
12
12
 
13
13
  const CODEX_APP_SERVER_METADATA_FILE = "runtime.json";
@@ -28,11 +28,13 @@ export async function runCodexAppServerProcess({
28
28
  process.once("SIGTERM", abortStartup);
29
29
  process.once("SIGINT", abortStartup);
30
30
  const codexHome = env.CODEX_HOME || path.join(os.homedir(), ".codex");
31
+ // A custom endpoint's home brings its own model's catalogue row.
32
+ const customModels = await readCodexCustomModels(codexHome);
31
33
  const catalogPath = await prepareCodexModelCatalog({
32
34
  command,
33
35
  runtimeDir,
34
36
  bundled,
35
- additionalModels,
37
+ additionalModels: [...additionalModels, ...customModels],
36
38
  codexHome,
37
39
  signal: startup.signal
38
40
  });
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred } from "./codexTurnState.js";
2
2
  import { codexAppServerRequestIsInvalid } from "./codexProviderThreads.js";
3
3
  import { codexLocalImageInput, codexAppServerDeliveredTurn, sendCodexAppServerPrompt } from "./codexProviderTurns.js";
4
4
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -74,9 +74,9 @@ export function createCodexPromptDispatch({
74
74
  if (!deliveredTurnId) {
75
75
  throw new Error("Codex app-server accepted the prompt without returning a turn id.");
76
76
  }
77
- // Codex answers turn/start before it starts the turn; its notifications about the turn say when it has.
77
+ // Codex usually answers turn/start before it starts the turn; its notifications about the turn say when it has.
78
78
  if (!codexAppServerTurnStatusIsComplete(deliveredTurnStatus)) {
79
- journal.unstartedTurns.add(codexAppServerTurnKey(threadId, deliveredTurnId));
79
+ journal.markTurnUnstarted(threadId, deliveredTurnId);
80
80
  }
81
81
  delivery.pendingUserMessage(promptSessionId, clientUserMessageId)?.onNativeReceipt();
82
82
  await recovery.markTurnActive(sessionId, {
@@ -1,4 +1,4 @@
1
- import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
 
4
4
  /**
@@ -325,10 +325,7 @@ export function createCodexResultSettlement({ journal, output, createRuntime, pu
325
325
  normalizedThreadId,
326
326
  previousTurnId
327
327
  );
328
- journal.completedTurns.add(codexAppServerTurnKey(
329
- normalizedThreadId,
330
- previousTurnId
331
- ));
328
+ journal.markTurnCompleted(normalizedThreadId, previousTurnId);
332
329
  const previousResultKey = output.resultFinalizationKey(
333
330
  normalizedSessionId,
334
331
  normalizedThreadId,
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
5
5
  const CODEX_APP_SERVER_PROMPT_CLAIM_GRACE_MS = 15000;
6
+ const CODEX_APP_SERVER_REPORTED_TURN_LIMIT = 256;
6
7
  const CODEX_APP_SERVER_STOPPED_BY_SHUTDOWN_EVENT = "codex-app-server-stopped-by-shutdown";
7
8
 
8
9
  /**
@@ -46,6 +47,55 @@ function codexAppServerDateValueMs(value = "") {
46
47
  return Number.isFinite(parsed) ? parsed : 0;
47
48
  }
48
49
 
50
+ /**
51
+ * Which turns Codex accepted with turn/start but has not reported yet: until it reports one, its thread still reads
52
+ * idle, so an idle read cannot settle it. Codex's turn/started can also arrive before its answer to turn/start, so the
53
+ * turns it reported most recently are remembered and never marked as not started. Only a turn whose answer is still on
54
+ * its way needs that, so the newest few are enough.
55
+ * @param {Set<string>} completedTurns the journal's settled turn keys, which markTurnCompleted adds to
56
+ */
57
+ function createCodexAppServerTurnStarts(completedTurns) {
58
+ const unstartedTurns = new Set();
59
+ // Oldest first.
60
+ const reportedTurns = new Set();
61
+
62
+ /** Codex reported this turn, in a notification or by listing it as finished, so it has started it. */
63
+ function noteTurnReported(threadId = "", turnId = "") {
64
+ if (!normalizeText(turnId)) return;
65
+ const key = codexAppServerTurnKey(threadId, turnId);
66
+ unstartedTurns.delete(key);
67
+ reportedTurns.delete(key);
68
+ reportedTurns.add(key);
69
+ if (reportedTurns.size > CODEX_APP_SERVER_REPORTED_TURN_LIMIT) {
70
+ reportedTurns.delete(reportedTurns.values().next().value);
71
+ }
72
+ }
73
+
74
+ /** A turn Codex accepted waits for its report before an idle read can settle it, unless Codex already reported it. */
75
+ function markTurnUnstarted(threadId = "", turnId = "") {
76
+ const key = codexAppServerTurnKey(threadId, turnId);
77
+ if (!reportedTurns.has(key)) unstartedTurns.add(key);
78
+ }
79
+
80
+ function turnIsUnstarted(threadId = "", turnId = "") {
81
+ return unstartedTurns.has(codexAppServerTurnKey(threadId, turnId));
82
+ }
83
+
84
+ /** The run owner settled this turn: nothing waits for Codex to start it any more. */
85
+ function markTurnCompleted(threadId = "", turnId = "") {
86
+ const key = codexAppServerTurnKey(threadId, turnId);
87
+ completedTurns.add(key);
88
+ unstartedTurns.delete(key);
89
+ }
90
+
91
+ return Object.freeze({
92
+ noteTurnReported,
93
+ markTurnUnstarted,
94
+ turnIsUnstarted,
95
+ markTurnCompleted
96
+ });
97
+ }
98
+
49
99
  /**
50
100
  * Native run claims, guarded journal writes and status retirement. The containing run owner shares these exact Sets
51
101
  * and Maps by reference.
@@ -88,8 +138,7 @@ export function createCodexRunJournal({
88
138
  isClosing
89
139
  }) {
90
140
  const codexAppServerCompletedTurns = new Set();
91
- // Turns Codex accepted with turn/start but has not yet reported: until it does, its thread still reads idle.
92
- const codexAppServerUnstartedTurns = new Set();
141
+ const turnStarts = createCodexAppServerTurnStarts(codexAppServerCompletedTurns);
93
142
  const codexAppServerProcessedTurns = new Set();
94
143
  const codexAppServerActiveTimers = new Map();
95
144
  const codexAppServerFinalizingTimers = new Map();
@@ -639,7 +688,10 @@ export function createCodexRunJournal({
639
688
 
640
689
  return Object.freeze({
641
690
  completedTurns: codexAppServerCompletedTurns,
642
- unstartedTurns: codexAppServerUnstartedTurns,
691
+ noteTurnReported: turnStarts.noteTurnReported,
692
+ markTurnUnstarted: turnStarts.markTurnUnstarted,
693
+ turnIsUnstarted: turnStarts.turnIsUnstarted,
694
+ markTurnCompleted: turnStarts.markTurnCompleted,
643
695
  processedTurns: codexAppServerProcessedTurns,
644
696
  activeTimers: codexAppServerActiveTimers,
645
697
  finalizingTimers: codexAppServerFinalizingTimers,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage } from "./codexTurnState.js";
2
2
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { codexAppServerStatusFromValue } from "./codexEvents.js";
4
4
  import { codexAppServerThreadRawValue, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
@@ -200,12 +200,16 @@ export function createCodexThreadStatus({ journal, settlement, recovery, output,
200
200
  turnId
201
201
  };
202
202
  }
203
+ // Codex lists the tracked turn as finished: it ran, even if its notifications about it were missed.
204
+ if (trackedProviderTurn && codexAppServerObservedTurnFinished(thread, trackedProviderTurn.turnId)) {
205
+ journal.noteTurnReported(trackedProviderTurn.threadId, trackedProviderTurn.turnId);
206
+ }
203
207
  // Codex has accepted the tracked turn but not started it. Until it does, its thread reads idle and lists the
204
208
  // turn as interrupted, so only Codex's own report about the turn settles it.
205
209
  if (
206
210
  trackedProviderTurn?.state === "active" &&
207
211
  !codexAppServerTurnStatusIsActive(status) &&
208
- journal.unstartedTurns.has(codexAppServerTurnKey(trackedProviderTurn.threadId, trackedProviderTurn.turnId))
212
+ journal.turnIsUnstarted(trackedProviderTurn.threadId, trackedProviderTurn.turnId)
209
213
  ) {
210
214
  debugLog("appServerThread.reconcile.awaitingProviderStart", {
211
215
  sessionId: normalizedSessionId,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity } from "./codexTurnState.js";
2
2
  import { codexAppServerThreadTurnId } from "./codexThreadSnapshot.js";
3
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
@@ -201,7 +201,7 @@ export function createCodexTurnEndings({
201
201
  };
202
202
  }
203
203
  if (journal.runInputSource(codexAppServerAgentRun(session)) === "terminal") {
204
- journal.completedTurns.add(codexAppServerTurnKey(normalizedThreadId, normalizedTurnId));
204
+ journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
205
205
  output.cleanupUntrackedTurn(normalizedThreadId, normalizedTurnId);
206
206
  await journal.markTurnIdle(normalizedSessionId, {
207
207
  status: normalizedStatus,
@@ -217,7 +217,7 @@ export function createCodexTurnEndings({
217
217
  const alreadyFinalizing = existingTurn.state === "finalizing" &&
218
218
  existingTurn.threadId === normalizedThreadId &&
219
219
  existingTurn.turnId === normalizedTurnId;
220
- journal.completedTurns.add(codexAppServerTurnKey(normalizedThreadId, normalizedTurnId));
220
+ journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
221
221
  if (!alreadyFinalizing) {
222
222
  await journal.markTurnFinalizing(normalizedSessionId, {
223
223
  status: normalizedStatus,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor } from "./codexTurnState.js";
2
2
  import { codexAppServerProviderTurnForOperation, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
3
3
  import { createCodexTurnActivity } from "./codexTurnActivity.js";
4
4
  import { createCodexTurnEndings } from "./codexTurnEndings.js";
@@ -270,16 +270,21 @@ export function createCodexTurnRecovery({
270
270
  if (!codexAppServerTurnStatusIsComplete(status)) {
271
271
  return currentSession;
272
272
  }
273
- if (journal.unstartedTurns.has(codexAppServerTurnKey(currentTurn.threadId, currentTurn.turnId))) {
274
- // Codex has accepted this turn but not started it, and its thread reads idle until it does.
275
- debugLog("appServerTurn.reconcile.awaitingProviderStart", {
276
- sessionId,
277
- status,
278
- threadId: currentTurn.threadId,
279
- turnId: currentTurn.turnId
280
- });
281
- scheduleCodexAppServerActiveRecovery(sessionId);
282
- return currentSession;
273
+ if (journal.turnIsUnstarted(currentTurn.threadId, currentTurn.turnId)) {
274
+ // Codex may have accepted this turn without starting it: its thread reads idle until it does. Only Codex's own
275
+ // listing of the turn as finished settles it before a notification about the turn arrives.
276
+ const observed = await codexAppServerReadThreadStatus(provider, currentTurn.threadId, { observeLatestTurn: true });
277
+ if (!codexAppServerObservedTurnFinished(observed, currentTurn.turnId)) {
278
+ debugLog("appServerTurn.reconcile.awaitingProviderStart", {
279
+ sessionId,
280
+ status,
281
+ threadId: currentTurn.threadId,
282
+ turnId: currentTurn.turnId
283
+ });
284
+ scheduleCodexAppServerActiveRecovery(sessionId);
285
+ return currentSession;
286
+ }
287
+ journal.noteTurnReported(currentTurn.threadId, currentTurn.turnId);
283
288
  }
284
289
  const completedTurnId = providerTurnId || currentTurn.turnId;
285
290
  debugLog("appServerTurn.reconcile.complete", {
@@ -38,6 +38,19 @@ function codexAppServerThreadStatus(thread = {}) {
38
38
  return liveStatus;
39
39
  }
40
40
 
41
+ /**
42
+ * Whether a thread read lists exactly this turn as finished, completed or failed. Codex lists a turn it has accepted
43
+ * but not yet started as interrupted, so only these two say that it ran.
44
+ * @param {Record<string, unknown> | null} thread a read with its latest turn observed
45
+ * @param {string} turnId
46
+ */
47
+ function codexAppServerObservedTurnFinished(thread = null, turnId = "") {
48
+ const observed = isRecord(thread?.observedTurn) ? thread.observedTurn : null;
49
+ const normalizedTurnId = normalizeNullableText(turnId);
50
+ if (!observed || !normalizedTurnId || normalizeNullableText(observed.id) !== normalizedTurnId) return false;
51
+ return ["completed", "failed"].includes(normalizeNullableText(observed.status));
52
+ }
53
+
41
54
  /**
42
55
  * Reads a native thread's status, and with observeLatestTurn its latest turn too.
43
56
  * @param {import("./codexProvider.js").CodexAppServerAgentProvider | null} provider
@@ -604,4 +617,4 @@ function codexPromptInputFromRequest(input = {}) {
604
617
  return codexAppServerMessageText(input);
605
618
  }
606
619
 
607
- export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };
620
+ export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };