@jskit-ai/assistant-core 0.2.18 → 0.2.20

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
@@ -7,7 +7,10 @@
7
7
  - The OpenCode version mismatch, Claude control-request timeout and unreadable saved Claude conversation errors carry `params` for their catalog English.
8
8
  - The assistant settings resource sends its system prompt field messages in the coded `{ code, message, params }` form.
9
9
 
10
- ### New message keys (163)
10
+ - 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.
11
+ - `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.
12
+
13
+ ### New message keys (164)
11
14
 
12
15
  <details><summary>Key list</summary>
13
16
 
@@ -174,10 +177,11 @@
174
177
  - `jskit.errors.assistant_tool_output_invalid`
175
178
  - `jskit.errors.assistant_tool_unknown`
176
179
  - `jskit.errors.assistant_claude_control_timeout`
180
+ - `jskit.errors.assistant_custom_endpoint_unreachable`
177
181
 
178
182
  </details>
179
183
 
180
- ### Codes (163)
184
+ ### Codes (164)
181
185
 
182
186
  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
187
 
@@ -346,6 +350,7 @@ Error, field-error and message codes this package now sends, with their English.
346
350
  | `assistant_tool_output_invalid` | Assistant tool output validation failed. |
347
351
  | `assistant_tool_unknown` | Unknown tool. |
348
352
  | `assistant_claude_control_timeout` | Claude {request} timed out. |
353
+ | `assistant_custom_endpoint_unreachable` | Your model server at {upstream} isn't answering. Start it, then send your message again. |
349
354
 
350
355
  ## 0.2.0 - 2026-10-29
351
356
 
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.18",
12
- "@jskit-ai/http-runtime": "0.2.8",
13
- "@jskit-ai/kernel": "0.2.8",
11
+ "@jskit-ai/assistant-core": "0.2.20",
12
+ "@jskit-ai/http-runtime": "0.2.9",
13
+ "@jskit-ai/kernel": "0.2.9",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.9",
18
- "@jskit-ai/connectors-catalog": "0.2.9"
17
+ "@jskit-ai/connectors-core": "0.2.10",
18
+ "@jskit-ai/connectors-catalog": "0.2.10"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
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.18",
3
+ "version": "0.2.20",
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",
@@ -51,8 +52,8 @@
51
52
  "@ai-sdk/togetherai": "^3.0.49",
52
53
  "@ai-sdk/xai": "^4.0.58",
53
54
  "@jskit-ai/agent": "0.1.1",
54
- "@jskit-ai/resource-core": "0.1.199",
55
- "@jskit-ai/resource-crud-core": "0.1.199",
55
+ "@jskit-ai/resource-core": "0.1.200",
56
+ "@jskit-ai/resource-crud-core": "0.1.200",
56
57
  "@mdi/js": "^7.4.47",
57
58
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
59
  "ai": "^7.0.152",
@@ -61,8 +62,8 @@
61
62
  "ws": "^8.20.1"
62
63
  },
63
64
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.8",
65
- "@jskit-ai/kernel": "0.2.8",
65
+ "@jskit-ai/http-runtime": "0.2.9",
66
+ "@jskit-ai/kernel": "0.2.9",
66
67
  "vue": "^3.5.13",
67
68
  "vuetify": "^4.0.0"
68
69
  },
@@ -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, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerTurnKey, 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,6 +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
244
  if (method === "thread/tokenUsage/updated") {
243
245
  notificationQueue.run(notificationContext, async () => {
244
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 { codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred, codexAppServerTurnKey } 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,6 +74,10 @@ 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.
78
+ if (!codexAppServerTurnStatusIsComplete(deliveredTurnStatus)) {
79
+ journal.unstartedTurns.add(codexAppServerTurnKey(threadId, deliveredTurnId));
80
+ }
77
81
  delivery.pendingUserMessage(promptSessionId, clientUserMessageId)?.onNativeReceipt();
78
82
  await recovery.markTurnActive(sessionId, {
79
83
  requireTrackedTurn: true,
@@ -88,6 +88,8 @@ export function createCodexRunJournal({
88
88
  isClosing
89
89
  }) {
90
90
  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();
91
93
  const codexAppServerProcessedTurns = new Set();
92
94
  const codexAppServerActiveTimers = new Map();
93
95
  const codexAppServerFinalizingTimers = new Map();
@@ -637,6 +639,7 @@ export function createCodexRunJournal({
637
639
 
638
640
  return Object.freeze({
639
641
  completedTurns: codexAppServerCompletedTurns,
642
+ unstartedTurns: codexAppServerUnstartedTurns,
640
643
  processedTurns: codexAppServerProcessedTurns,
641
644
  activeTimers: codexAppServerActiveTimers,
642
645
  finalizingTimers: codexAppServerFinalizingTimers,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage, codexAppServerTurnKey } 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,6 +200,27 @@ export function createCodexThreadStatus({ journal, settlement, recovery, output,
200
200
  turnId
201
201
  };
202
202
  }
203
+ // Codex has accepted the tracked turn but not started it. Until it does, its thread reads idle and lists the
204
+ // turn as interrupted, so only Codex's own report about the turn settles it.
205
+ if (
206
+ trackedProviderTurn?.state === "active" &&
207
+ !codexAppServerTurnStatusIsActive(status) &&
208
+ journal.unstartedTurns.has(codexAppServerTurnKey(trackedProviderTurn.threadId, trackedProviderTurn.turnId))
209
+ ) {
210
+ debugLog("appServerThread.reconcile.awaitingProviderStart", {
211
+ sessionId: normalizedSessionId,
212
+ source: normalizeText(source),
213
+ status,
214
+ threadId: trackedProviderTurn.threadId,
215
+ turnId: trackedProviderTurn.turnId
216
+ });
217
+ return {
218
+ ok: true,
219
+ reason: "provider_turn_not_started",
220
+ status: trackedProviderTurn.status || "inProgress",
221
+ turnId: trackedProviderTurn.turnId
222
+ };
223
+ }
203
224
  if (
204
225
  trackedProviderTurn &&
205
226
  turnId &&
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor, codexAppServerTurnKey } 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,6 +270,17 @@ 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;
283
+ }
273
284
  const completedTurnId = providerTurnId || currentTurn.turnId;
274
285
  debugLog("appServerTurn.reconcile.complete", {
275
286
  sessionId,
@@ -0,0 +1,188 @@
1
+ import { CUSTOM_AI_PLACEHOLDER_TOKEN } from "../../shared/nativeProviders.js";
2
+
3
+ // Loading a model on the person's own server can take this long on first use.
4
+ export const CUSTOM_ENDPOINT_CHECK_TIMEOUT_MS = 120_000;
5
+ const CONTEXT_READ_TIMEOUT_MS = 5_000;
6
+ const RESPONSE_LIMIT_BYTES = 256 * 1024;
7
+
8
+ const ENGINE_APIS = Object.freeze({
9
+ codex: Object.freeze({
10
+ path: "/responses",
11
+ name: "Responses API",
12
+ urlHint: "Codex needs an OpenAI Responses-compatible base URL, which usually ends in /v1.",
13
+ body: (model) => ({ model, input: "Reply with OK.", max_output_tokens: 64, store: false }),
14
+ usable: (result) => Boolean(result?.id) && Array.isArray(result.output) &&
15
+ ["completed", "incomplete"].includes(result.status)
16
+ }),
17
+ claude: Object.freeze({
18
+ path: "/v1/messages",
19
+ name: "Anthropic Messages API",
20
+ urlHint: "Claude Code needs an Anthropic-compatible base URL without /v1.",
21
+ body: (model) => ({ model, max_tokens: 64, messages: [{ role: "user", content: "Reply with OK." }] }),
22
+ usable: (result) => Boolean(result?.id) && result.type === "message" && Array.isArray(result.content)
23
+ }),
24
+ opencode: Object.freeze({
25
+ path: "/chat/completions",
26
+ name: "Chat Completions API",
27
+ urlHint: "OpenCode needs an OpenAI-compatible base URL, which usually ends in /v1.",
28
+ body: (model) => ({ model, max_tokens: 64, messages: [{ role: "user", content: "Reply with OK." }] }),
29
+ usable: (result) => Boolean(result?.id) && Array.isArray(result.choices) && result.choices.length > 0
30
+ })
31
+ });
32
+
33
+ function checkError(code, message) {
34
+ return Object.assign(new Error(message), { code });
35
+ }
36
+
37
+ async function readLimitedJson(response) {
38
+ // readable-check: allow optional-call - platform object: a fetch Response body may be absent
39
+ const reader = response.body?.getReader?.();
40
+ if (!reader) return null;
41
+ const chunks = [];
42
+ let size = 0;
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ size += value.byteLength;
47
+ if (size > RESPONSE_LIMIT_BYTES) {
48
+ await reader.cancel();
49
+ return null;
50
+ }
51
+ chunks.push(value);
52
+ }
53
+ try {
54
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** A model server answers 404 both for a wrong path and for a model it does not have. */
61
+ function namesMissingModel(result, model) {
62
+ const message = String(result?.error?.message ?? result?.error ?? result?.message ?? "").toLowerCase();
63
+ return message.includes("model") && (message.includes("not found") || message.includes(model.toLowerCase()));
64
+ }
65
+
66
+ /**
67
+ * One small request on the engine's own API with the entered model: the same
68
+ * proof as a curated key check, for a server the person runs. Never exposes
69
+ * the server's raw reply.
70
+ * @param {{ engineId: "codex" | "claude" | "opencode", baseUrl: string, model: string, apiKey?: string,
71
+ * fetchImpl?: typeof fetch, timeoutMs?: number }} input
72
+ */
73
+ export async function verifyCustomEndpoint({
74
+ engineId, baseUrl, model, apiKey = "", fetchImpl = fetch, timeoutMs = CUSTOM_ENDPOINT_CHECK_TIMEOUT_MS
75
+ }) {
76
+ const api = ENGINE_APIS[engineId];
77
+ if (!api) throw new TypeError("Custom endpoints connect Codex, Claude Code or OpenCode.");
78
+ const root = baseUrl.replace(/\/$/u, "");
79
+ const token = apiKey || (engineId === "claude" ? CUSTOM_AI_PLACEHOLDER_TOKEN : "");
80
+ let response;
81
+ try {
82
+ response = await fetchImpl(`${root}${api.path}`, {
83
+ method: "POST",
84
+ redirect: "error",
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
89
+ ...(engineId === "claude" ? { "anthropic-version": "2023-06-01" } : {})
90
+ },
91
+ body: JSON.stringify(api.body(model))
92
+ });
93
+ } catch {
94
+ throw checkError("custom_endpoint_unreachable",
95
+ `Nothing answers at ${root}. Start your model server, then choose Check again.`);
96
+ }
97
+ const result = await readLimitedJson(response);
98
+ if (response.status === 401 || response.status === 403) {
99
+ throw checkError("custom_endpoint_key_rejected",
100
+ `The server at ${root} rejected the key. Check the key, or leave it empty if the server needs none.`);
101
+ }
102
+ if (response.status === 404) {
103
+ throw namesMissingModel(result, model)
104
+ ? checkError("custom_endpoint_model_missing", `The server at ${root} has no model named ${model}.`)
105
+ : checkError("custom_endpoint_path_missing", `${root}${api.path} was not found. ${api.urlHint}`);
106
+ }
107
+ if (!response.ok) {
108
+ throw checkError("custom_endpoint_request_failed",
109
+ `The server at ${root} could not complete a test request (HTTP ${response.status}).`);
110
+ }
111
+ if (!api.usable(result)) {
112
+ throw checkError("custom_endpoint_answer_unusable",
113
+ `The server at ${root} did not return a usable ${api.name} answer. ${api.urlHint}`);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The context Ollama really loaded the model with, read from `/api/ps` after a
119
+ * check request loaded it. Ollama gives a 4,096-token context by default on
120
+ * smaller GPUs and cuts longer prompts without an error. Null when the server
121
+ * is not Ollama or no longer has the model loaded.
122
+ * @param {{ baseUrl: string, model: string, apiKey?: string, fetchImpl?: typeof fetch }} input
123
+ */
124
+ export async function readOllamaLoadedContext({ baseUrl, model, apiKey = "", fetchImpl = fetch }) {
125
+ const root = baseUrl.replace(/\/$/u, "").replace(/\/v1$/u, "");
126
+ let response;
127
+ try {
128
+ response = await fetchImpl(`${root}/api/ps`, {
129
+ redirect: "error",
130
+ signal: AbortSignal.timeout(CONTEXT_READ_TIMEOUT_MS),
131
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
132
+ });
133
+ } catch {
134
+ return null;
135
+ }
136
+ const result = response.ok ? await readLimitedJson(response) : null;
137
+ if (!response.ok) {
138
+ // readable-check: allow optional-call - platform object: a fetch Response body may be absent
139
+ await response.body?.cancel?.();
140
+ }
141
+ if (!Array.isArray(result?.models)) return null;
142
+ const names = new Set([model, model.includes(":") ? model : `${model}:latest`]);
143
+ const loaded = result.models.find((entry) => names.has(entry?.name) || names.has(entry?.model));
144
+ const contextLength = Number(loaded?.context_length);
145
+ return Number.isSafeInteger(contextLength) && contextLength > 0 ? contextLength : null;
146
+ }
147
+
148
+ /**
149
+ * Whether anything answers at a saved endpoint: one read of its model list,
150
+ * which loads no model. Any HTTP answer counts, even a refusal; Health shows it
151
+ * and never polls.
152
+ * @param {{ engineId: "codex" | "claude" | "opencode", baseUrl: string, fetchImpl?: typeof fetch }} input
153
+ */
154
+ export async function probeCustomEndpoint({ engineId, baseUrl, fetchImpl = fetch }) {
155
+ const root = baseUrl.replace(/\/$/u, "");
156
+ try {
157
+ const response = await fetchImpl(engineId === "claude" ? `${root}/v1/models` : `${root}/models`, {
158
+ redirect: "error",
159
+ signal: AbortSignal.timeout(CONTEXT_READ_TIMEOUT_MS)
160
+ });
161
+ // readable-check: allow optional-call - platform object: a fetch Response body may be absent
162
+ await response.body?.cancel?.();
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Connect-time proof for one engine: the engine's API answers with the entered
171
+ * model, and an Ollama server loaded it with at least the entered context.
172
+ * @param {{ engineId: "codex" | "claude" | "opencode", baseUrl: string, model: string, contextWindow: number,
173
+ * apiKey?: string, fetchImpl?: typeof fetch, timeoutMs?: number }} input
174
+ */
175
+ export async function checkCustomEndpoint({ contextWindow, ...input }) {
176
+ await verifyCustomEndpoint(input);
177
+ const loaded = await readOllamaLoadedContext(input);
178
+ if (loaded !== null && loaded < contextWindow) {
179
+ throw checkError("custom_endpoint_context_too_small",
180
+ `Ollama loaded ${input.model} with ${loaded.toLocaleString("en-US")} tokens of context, less than the ` +
181
+ `${contextWindow.toLocaleString("en-US")} entered. It would cut prompts without an error. Restart it with ` +
182
+ `OLLAMA_CONTEXT_LENGTH=${contextWindow}, then choose Check again.`);
183
+ }
184
+ return Object.freeze({
185
+ ok: true,
186
+ loadedContext: loaded
187
+ });
188
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { realpath } from "node:fs/promises";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { bindClaudeConversationAccount, claudeConnectionIdentity, claudeFlagSettings, claudeModelConfiguration, readClaudeCodeAuthStatus } from "../claudeProcess.js";
8
- import { nativeAiModel, nativeAiProvider } from "../../../shared/nativeProviders.js";
8
+ import { connectionAiProvider } from "../../../shared/nativeProviders.js";
9
9
  import { claudeNativeMessageId, requireClaudeSessionId, listClaudeConversationStorage } from "../claudeHistory.js";
10
10
  import { createClaudeConversationOwner } from "../claudeTurn.js";
11
11
  import { CLAUDE_APPLICATION_TOOL_SERVER } from "../claudeTools.js";
@@ -221,8 +221,8 @@ export function createClaudeConversationDriver({
221
221
  if (requireBound && !accountBinding?.connectionIdentities?.[providerId]) {
222
222
  throw new Error("The completed Claude response has no saved provider account fingerprint.");
223
223
  }
224
- const provider = nativeAiProvider(providerId);
225
- const model = nativeAiModel(connection.model, providerId);
224
+ const provider = connectionAiProvider(providerId, connection);
225
+ const model = provider?.models.find(({ id }) => id === connection.model) || null;
226
226
  if (providerId !== "anthropic" && (!provider || !model)) throw Object.assign(new Error("This exact provider/model is not supported by the Claude integration. Choose a supported model without renaming it."), {
227
227
  messageCode: "assistant.claude.modelUnsupported", messageParams: {}
228
228
  });
@@ -231,7 +231,8 @@ export function createClaudeConversationDriver({
231
231
  messageCode: "assistant.claude.reasoningEffortUnsupported", messageParams: {}
232
232
  });
233
233
  selected = claudeModelConfiguration({ providerId, model: connection.model }, {
234
- apiKey: connection.apiKey, baseUrl: provider?.claudeBaseUrl || connection.baseURL || "https://api.anthropic.com"
234
+ apiKey: connection.apiKey, provider: connection.provider,
235
+ baseUrl: provider?.claudeBaseUrl || connection.baseURL || "https://api.anthropic.com"
235
236
  });
236
237
  identity = claudeConnectionIdentity(configRoot, providerId, selected.env.ANTHROPIC_BASE_URL, connection.apiKey);
237
238
  } else {
@@ -10,7 +10,7 @@ import { codexToolFreeConfiguration } from "../codexIsolation.js";
10
10
  import { codexCommandHookCommand } from "../commandWrapper.js";
11
11
  import { validateConnectionModel } from "../configuration.js";
12
12
  import { jsonHash as hash } from "../normalize.js";
13
- import { nativeAiProvider, nativeAiModel } from "../../../shared/nativeProviders.js";
13
+ import { connectionAiProvider } from "../../../shared/nativeProviders.js";
14
14
 
15
15
  /**
16
16
  * The native side of one driver conversation: its observation, the provider it acquires and stops, the account it
@@ -197,8 +197,8 @@ export function createCodexDriverConnection({
197
197
  }
198
198
  const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
199
199
  signal.throwIfAborted();
200
- const provider = nativeAiProvider(connection.providerId);
201
- const model = nativeAiModel(connection.model, connection.providerId);
200
+ const provider = connectionAiProvider(connection.providerId, connection);
201
+ const model = provider?.models.find(({ id }) => id === connection.model) || null;
202
202
  if (!provider || !model) throw Object.assign(new Error("This exact provider/model is not supported by the Codex integration. Choose a supported model without renaming it."), {
203
203
  messageCode: "assistant.codex.modelUnsupported", messageParams: {}
204
204
  });
@@ -14,6 +14,7 @@ export {
14
14
  codexProviderConfiguration,
15
15
  codexProviderFileConfiguration,
16
16
  codexProviderCatalogPath,
17
+ readCodexCustomModels,
17
18
  verifyCodexProviderKey
18
19
  } from "../conversation/codexConfiguration.js";
19
20
  export { codexAuthOutputRequiresReconnect, createCodexAccountReader } from "../conversation/codexAccountReader.js";
@@ -0,0 +1,7 @@
1
+ export {
2
+ CUSTOM_ENDPOINT_CHECK_TIMEOUT_MS,
3
+ checkCustomEndpoint,
4
+ probeCustomEndpoint,
5
+ readOllamaLoadedContext,
6
+ verifyCustomEndpoint
7
+ } from "../conversation/customEndpoint.js";
@@ -29,6 +29,43 @@ const NATIVE_AI_PROVIDERS = Object.freeze([
29
29
  })
30
30
  ]);
31
31
 
32
+ // A model server the person runs or reaches. Its route comes from their saved
33
+ // connection, never from this table: one base URL and one model per engine.
34
+ const CUSTOM_AI_PROVIDER_ID = "custom";
35
+ const CUSTOM_AI_MINIMUM_CONTEXT = 32768;
36
+ // Claude Code needs a token: without one it sends the saved claude.ai login.
37
+ const CUSTOM_AI_PLACEHOLDER_TOKEN = "local";
38
+
39
+ /**
40
+ * The native shape of a custom endpoint for one engine. Codex gets reasoning
41
+ * levels (its catalogue needs them); Claude Code and OpenCode get none, so no
42
+ * effort the server may not understand is sent. Freeform patching and parallel
43
+ * tool calls stay off: Codex refuses Ollama's freeform apply_patch replies.
44
+ * @param {{ engineId?: string, baseUrl: string, model: string, contextWindow: number, label?: string }} connection
45
+ */
46
+ function customAiProvider({ engineId = "codex", baseUrl, model: modelId, contextWindow, label = "Custom endpoint" }) {
47
+ const variants = engineId === "codex" ? ["low", "medium", "high"] : [];
48
+ return Object.freeze({
49
+ id: CUSTOM_AI_PROVIDER_ID,
50
+ custom: true,
51
+ label,
52
+ baseUrl,
53
+ claudeBaseUrl: baseUrl,
54
+ claudeAutoCompactWindow: contextWindow,
55
+ webSearch: false,
56
+ models: Object.freeze([model(modelId, contextWindow, variants, {
57
+ label: modelId, defaultThinking: variants.length ? "medium" : "", parallelToolCalls: false,
58
+ codexHistoryRouting: true
59
+ })])
60
+ });
61
+ }
62
+
63
+ /** The provider of a resolved connection: a fixed route, or the custom endpoint it carries. */
64
+ function connectionAiProvider(providerId, connection) {
65
+ if (providerId === CUSTOM_AI_PROVIDER_ID) return connection?.provider?.custom === true ? connection.provider : null;
66
+ return nativeAiProvider(providerId);
67
+ }
68
+
32
69
  function nativeAiProvider(id) {
33
70
  return NATIVE_AI_PROVIDERS.find(provider => provider.id === id) || null;
34
71
  }
@@ -42,4 +79,13 @@ function nativeAiModel(id, providerId = "") {
42
79
  return null;
43
80
  }
44
81
 
45
- export { NATIVE_AI_PROVIDERS, nativeAiProvider, nativeAiModel };
82
+ export {
83
+ CUSTOM_AI_MINIMUM_CONTEXT,
84
+ CUSTOM_AI_PLACEHOLDER_TOKEN,
85
+ CUSTOM_AI_PROVIDER_ID,
86
+ NATIVE_AI_PROVIDERS,
87
+ connectionAiProvider,
88
+ customAiProvider,
89
+ nativeAiProvider,
90
+ nativeAiModel
91
+ };
@@ -1910,6 +1910,51 @@ test("original dispatch steering preserves deferred versus new-turn recovery and
1910
1910
  }
1911
1911
  });
1912
1912
 
1913
+ // Codex answers turn/start before it starts the turn. Until its turn/started notification, codex-cli 0.163.0 reads
1914
+ // the thread as idle and lists the new turn as interrupted, so neither read means the turn ended. On 2026-11-06 the
1915
+ // starting watchdog's status read landed in that gap: the turn was marked finished 0.46 s after delivery, and Codex's
1916
+ // answer 33 s later was dropped behind "Codex finished, but Vibe64 could not recover its final response".
1917
+ for (const read of ["starting watchdog", "status check"]) {
1918
+ test(`a delivered turn Codex has not started stays active through a ${read} and keeps its answer`, async t => {
1919
+ const notices = [];
1920
+ const f = await receiptFixture({
1921
+ finalizingGraceMs: 30,
1922
+ outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); }
1923
+ });
1924
+ t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
1925
+ const context = dispatchContext(f);
1926
+ let codexStatus = "idle";
1927
+ f.run = { ...f.run, state: "starting", providerTurnId: "", providerStatus: "", outerTurnId: context.messageId };
1928
+ f.provider.sendTurn = async () => ({ id: f.turnId, status: "inProgress" });
1929
+ f.provider.readThreadStatus = async () => ({ raw: { status: { type: codexStatus } } });
1930
+ f.history = [turn(f.turnId, "interrupted")];
1931
+ f.observe();
1932
+ assert.equal((await f.owner.startTurn(f.sessionId, {}, context)).providerFailure, "");
1933
+ assert.equal(f.run.state, "active");
1934
+ if (read === "starting watchdog") {
1935
+ await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false });
1936
+ } else {
1937
+ await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, {
1938
+ observeLatestTurn: true, source: "wellbeing"
1939
+ });
1940
+ }
1941
+ await f.drain();
1942
+ assert.equal(f.run.state, "active", "An idle read before turn/started does not end the turn");
1943
+ assert.equal(f.run.providerTurnId, f.turnId);
1944
+ codexStatus = "active";
1945
+ f.history = [turn(f.turnId, "inProgress")];
1946
+ f.emit({ method: "turn/started", params: { threadId: f.threadId, turn: { id: f.turnId, status: "inProgress" } } });
1947
+ await f.drain();
1948
+ codexStatus = "idle";
1949
+ f.history = [turn(f.turnId, "completed", "The complete reply.")];
1950
+ f.emit({ method: "turn/completed", params: { threadId: f.threadId, turn: { id: f.turnId, status: "completed" } } });
1951
+ await f.drain();
1952
+ assert.equal(f.run.state, "completed");
1953
+ assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["The complete reply."]);
1954
+ assert.deepEqual(notices, []);
1955
+ });
1956
+ }
1957
+
1913
1958
  function controlContext(f, overrides = {}) {
1914
1959
  return {
1915
1960
  runtime: f.runtime,
@@ -0,0 +1,218 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import { CUSTOM_AI_PLACEHOLDER_TOKEN, customAiProvider } from "../src/shared/nativeProviders.js";
9
+ import {
10
+ codexProviderConfiguration,
11
+ codexProviderFileConfiguration,
12
+ codexProviderModelCatalog,
13
+ readCodexCustomModels
14
+ } from "../src/server/conversation/codexConfiguration.js";
15
+ import { claudeModelConfiguration } from "../src/server/conversation/claudeProcess.js";
16
+ import { prepareCodexHistory, startCodexHistoryAdapter } from "../src/server/conversation/codexHistoryAdapter.js";
17
+ import { checkCustomEndpoint, probeCustomEndpoint, verifyCustomEndpoint } from "../src/server/conversation/customEndpoint.js";
18
+
19
+ const recorded = JSON.parse(await readFile(new URL("./fixtures/customEndpoint/ollama-0.40.15.json", import.meta.url), "utf8"));
20
+ const MODEL = recorded.model;
21
+
22
+ /** A stub model server replaying Ollama 0.40.15's answers on all three engine APIs. */
23
+ async function startOllamaStub(t, { context = 65536, status = 200, missingModel = false } = {}) {
24
+ const requests = [];
25
+ const server = createServer(async (request, response) => {
26
+ const chunks = [];
27
+ for await (const chunk of request) chunks.push(chunk);
28
+ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
29
+ requests.push({ method: request.method, url: request.url, headers: request.headers, body });
30
+ const send = (code, value) => response.writeHead(code, { "content-type": "application/json" }).end(JSON.stringify(value));
31
+ if (status !== 200) return send(status, { error: "denied" });
32
+ if (request.method === "GET" && request.url === "/api/ps") {
33
+ return send(200, context === 65536 ? recorded.psRaisedContext : recorded.psDefaultContext);
34
+ }
35
+ if (body?.model !== MODEL || missingModel) return send(404, recorded.modelMissing);
36
+ if (request.url === "/v1/responses") return send(200, recorded.response);
37
+ if (request.url === "/v1/messages") return send(200, recorded.messages);
38
+ if (request.url === "/v1/chat/completions") return send(200, recorded.chatCompletion);
39
+ return send(404, { error: "404 page not found" });
40
+ });
41
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
42
+ t.after(() => new Promise((resolve) => server.close(resolve)));
43
+ return { root: `http://127.0.0.1:${server.address().port}`, requests };
44
+ }
45
+
46
+ test("a custom endpoint has one model, no freeform patching and no parallel tool calls", () => {
47
+ const codex = customAiProvider({ engineId: "codex", baseUrl: "http://127.0.0.1:11434/v1", model: MODEL, contextWindow: 65536 });
48
+ assert.equal(codex.id, "custom");
49
+ assert.equal(codex.custom, true);
50
+ assert.deepEqual(codex.models.map(({ id }) => id), [MODEL]);
51
+ assert.deepEqual(codex.models[0].variants, ["low", "medium", "high"]);
52
+ assert.equal(codex.models[0].freeformPatch, undefined);
53
+ const [row] = codexProviderModelCatalog({ models: codex.models }).models;
54
+ assert.equal(row.slug, MODEL);
55
+ assert.equal(row.context_window, 65536);
56
+ assert.equal(row.supports_parallel_tool_calls, false);
57
+ assert.equal(Object.hasOwn(row, "apply_patch_tool_type"), false);
58
+ const claude = customAiProvider({ engineId: "claude", baseUrl: "http://127.0.0.1:11434", model: MODEL, contextWindow: 65536 });
59
+ assert.deepEqual(claude.models[0].variants, []);
60
+ assert.equal(claude.claudeBaseUrl, "http://127.0.0.1:11434");
61
+ });
62
+
63
+ test("Codex sends no bearer token to a custom endpoint without a key, and its home names the endpoint", async (t) => {
64
+ const provider = customAiProvider({ engineId: "codex", baseUrl: "http://127.0.0.1:11434/v1", model: MODEL, contextWindow: 65536 });
65
+ const config = codexProviderConfiguration(provider, "")["model_providers.custom"];
66
+ assert.equal(config.base_url, "http://127.0.0.1:11434/v1");
67
+ assert.equal(config.requires_openai_auth, false);
68
+ assert.equal(Object.hasOwn(config, "experimental_bearer_token"), false);
69
+ assert.equal(codexProviderConfiguration(provider, "their-key")["model_providers.custom"].experimental_bearer_token, "their-key");
70
+ const home = await mkdtemp(path.join(os.tmpdir(), "custom-endpoint-home-"));
71
+ t.after(() => rm(home, { recursive: true, force: true }));
72
+ const file = codexProviderFileConfiguration(provider, "", home);
73
+ assert.doesNotMatch(file, /experimental_bearer_token/u);
74
+ assert.match(file, /^model_provider = "custom"$/mu);
75
+ assert.match(file, /^model_reasoning_effort = "medium"$/mu);
76
+ await writeFile(path.join(home, "config.toml"), file);
77
+ await writeFile(path.join(home, "models.json"), JSON.stringify(codexProviderModelCatalog({ models: provider.models })));
78
+ assert.deepEqual((await readCodexCustomModels(home)).map(({ slug }) => slug), [MODEL]);
79
+ const deepseek = await mkdtemp(path.join(os.tmpdir(), "curated-home-"));
80
+ t.after(() => rm(deepseek, { recursive: true, force: true }));
81
+ await writeFile(path.join(deepseek, "config.toml"), 'model_provider = "deepseek"\n[model_providers.deepseek]\nbase_url = "https://api.deepseek.com/"\n');
82
+ assert.deepEqual(await readCodexCustomModels(deepseek), []);
83
+ });
84
+
85
+ test("Claude Code always sends a token to a custom endpoint, so the saved claude.ai login never goes there", () => {
86
+ const provider = customAiProvider({ engineId: "claude", baseUrl: "http://127.0.0.1:11434", model: MODEL, contextWindow: 65536 });
87
+ const keyless = claudeModelConfiguration({ providerId: "custom", model: MODEL }, { provider, apiKey: "", baseUrl: provider.claudeBaseUrl });
88
+ assert.equal(keyless.model, MODEL);
89
+ assert.equal(keyless.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:11434");
90
+ assert.equal(keyless.env.ANTHROPIC_AUTH_TOKEN, CUSTOM_AI_PLACEHOLDER_TOKEN);
91
+ assert.equal(keyless.env.ANTHROPIC_API_KEY, "");
92
+ assert.equal(keyless.env.CLAUDE_CODE_OAUTH_TOKEN, "");
93
+ for (const name of ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
94
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"]) {
95
+ assert.equal(keyless.env[name], MODEL, name);
96
+ }
97
+ assert.equal(keyless.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, "65536");
98
+ assert.equal(keyless.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, "16384");
99
+ const keyed = claudeModelConfiguration({ providerId: "custom", model: MODEL }, { provider, apiKey: "their-key", baseUrl: provider.claudeBaseUrl });
100
+ assert.equal(keyed.env.ANTHROPIC_AUTH_TOKEN, "their-key");
101
+ assert.throws(() => claudeModelConfiguration({ providerId: "custom", model: MODEL }, { apiKey: "", baseUrl: "http://127.0.0.1:11434" }),
102
+ /Choose a connected Claude Code model/u);
103
+ assert.throws(() => claudeModelConfiguration({ providerId: "custom", model: "another" }, { provider, apiKey: "" }),
104
+ /Choose a connected Claude Code model/u);
105
+ assert.equal(claudeModelConfiguration({ providerId: "anthropic", model: "opus" }, null).env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, "");
106
+ });
107
+
108
+ test("the connection check sends one request on each engine's own API and accepts Ollama's recorded answers", async (t) => {
109
+ const stub = await startOllamaStub(t);
110
+ await checkCustomEndpoint({ engineId: "codex", baseUrl: `${stub.root}/v1`, model: MODEL, contextWindow: 65536 });
111
+ await checkCustomEndpoint({ engineId: "opencode", baseUrl: `${stub.root}/v1/`, model: MODEL, contextWindow: 65536 });
112
+ const claude = await checkCustomEndpoint({ engineId: "claude", baseUrl: stub.root, model: MODEL, contextWindow: 65536 });
113
+ assert.equal(claude.loadedContext, 65536);
114
+ const posts = stub.requests.filter(({ method }) => method === "POST");
115
+ assert.deepEqual(posts.map(({ url }) => url), ["/v1/responses", "/v1/chat/completions", "/v1/messages"]);
116
+ assert.equal(posts[0].headers.authorization, undefined);
117
+ assert.equal(posts[1].headers.authorization, undefined);
118
+ assert.equal(posts[2].headers.authorization, `Bearer ${CUSTOM_AI_PLACEHOLDER_TOKEN}`);
119
+ assert.equal(posts[2].headers["anthropic-version"], "2023-06-01");
120
+ await verifyCustomEndpoint({ engineId: "codex", baseUrl: `${stub.root}/v1`, model: MODEL, apiKey: "their-key" });
121
+ assert.equal(stub.requests.at(-1).headers.authorization, "Bearer their-key");
122
+ });
123
+
124
+ test("the connection check refuses Ollama's silent 4,096-token default context", async (t) => {
125
+ const stub = await startOllamaStub(t, { context: 4096 });
126
+ await assert.rejects(checkCustomEndpoint({ engineId: "claude", baseUrl: stub.root, model: MODEL, contextWindow: 65536 }), (error) => {
127
+ assert.equal(error.code, "custom_endpoint_context_too_small");
128
+ assert.match(error.message, /Ollama loaded qwen3\.5:4b-q4_K_M with 4,096 tokens of context, less than the 65,536 entered/u);
129
+ assert.match(error.message, /OLLAMA_CONTEXT_LENGTH=65536/u);
130
+ return true;
131
+ });
132
+ // The entered length is what the engines compact by; a server loaded with more passes.
133
+ await checkCustomEndpoint({ engineId: "claude", baseUrl: stub.root, model: MODEL, contextWindow: 4096 });
134
+ });
135
+
136
+ test("the connection check names the fix for each failure", async (t) => {
137
+ const stub = await startOllamaStub(t);
138
+ const rejects = (input, code, pattern) => assert.rejects(verifyCustomEndpoint({ model: MODEL, ...input }), (error) => {
139
+ assert.equal(error.code, code);
140
+ assert.match(error.message, pattern);
141
+ return true;
142
+ });
143
+ await rejects({ engineId: "claude", baseUrl: `${stub.root}/v1` }, "custom_endpoint_path_missing",
144
+ /\/v1\/v1\/messages was not found\. Claude Code needs an Anthropic-compatible base URL without \/v1\./u);
145
+ await rejects({ engineId: "codex", baseUrl: stub.root }, "custom_endpoint_path_missing",
146
+ /Codex needs an OpenAI Responses-compatible base URL, which usually ends in \/v1\./u);
147
+ await rejects({ engineId: "opencode", baseUrl: `${stub.root}/v1`, model: "qwen9:1b" }, "custom_endpoint_model_missing",
148
+ /has no model named qwen9:1b\./u);
149
+ const denied = await startOllamaStub(t, { status: 401 });
150
+ await rejects({ engineId: "codex", baseUrl: `${denied.root}/v1` }, "custom_endpoint_key_rejected", /rejected the key/u);
151
+ const failing = await startOllamaStub(t, { status: 500 });
152
+ await rejects({ engineId: "codex", baseUrl: `${failing.root}/v1` }, "custom_endpoint_request_failed", /\(HTTP 500\)/u);
153
+ const closed = createServer();
154
+ await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve));
155
+ const { port } = closed.address();
156
+ await new Promise((resolve) => closed.close(resolve));
157
+ await rejects({ engineId: "opencode", baseUrl: `http://127.0.0.1:${port}/v1` }, "custom_endpoint_unreachable",
158
+ new RegExp(`Nothing answers at http://127\\.0\\.0\\.1:${port}/v1\\. Start your model server`, "u"));
159
+ const redirect = createServer((_request, response) => response.writeHead(302, { location: "http://example.test/" }).end());
160
+ await new Promise((resolve) => redirect.listen(0, "127.0.0.1", resolve));
161
+ t.after(() => new Promise((resolve) => redirect.close(resolve)));
162
+ await rejects({ engineId: "codex", baseUrl: `http://127.0.0.1:${redirect.address().port}/v1` }, "custom_endpoint_unreachable", /Nothing answers/u);
163
+ });
164
+
165
+ test("the Codex history adapter routes a custom endpoint to the base URL its thread names, in any runtime", async (t) => {
166
+ const calls = [];
167
+ const adapter = await startCodexHistoryAdapter({ token: randomUUID(),
168
+ fetchImpl: async (url, options) => {
169
+ calls.push({ url, ...options });
170
+ return new Response(JSON.stringify(recorded.response), { status: 200, headers: { "content-type": "application/json" } });
171
+ } });
172
+ t.after(() => adapter.close());
173
+ const config = { "model_providers.custom": { base_url: "http://127.0.0.1:11434/v1/", wire_api: "responses" } };
174
+ const prepared = await prepareCodexHistory({ modelProvider: "custom", config }, null, { baseUrl: adapter.baseUrl });
175
+ const routedUrl = prepared.config["model_providers.custom"].base_url;
176
+ assert.equal(routedUrl, `${adapter.baseUrl}/custom~${Buffer.from("http://127.0.0.1:11434/v1").toString("base64url")}`);
177
+ assert.equal(prepared.config["model_providers.custom"].wire_api, "responses");
178
+ const routed = await fetch(`${routedUrl}/responses`, { method: "POST", body: JSON.stringify({ model: MODEL, input: [] }) });
179
+ assert.equal(routed.status, 200);
180
+ assert.equal((await routed.json()).id, recorded.response.id);
181
+ assert.equal(calls.at(-1).url, "http://127.0.0.1:11434/v1/responses");
182
+ assert.equal(calls.at(-1).redirect, "error");
183
+ // A resumed thread keeps its route and gains its history path.
184
+ const resumed = await prepareCodexHistory(prepared, null, { baseUrl: adapter.baseUrl, historyPath: "/history.jsonl" });
185
+ assert.equal(resumed.config["model_providers.custom"].base_url,
186
+ `${routedUrl}/history/${Buffer.from("/history.jsonl").toString("base64url")}`);
187
+ // Only an http(s) base URL without credentials is routed.
188
+ for (const target of ["file:///etc/passwd", "http://user:secret@127.0.0.1:11434/v1", "not a url"]) {
189
+ const response = await fetch(`${adapter.baseUrl}/custom~${Buffer.from(target).toString("base64url")}/responses`,
190
+ { method: "POST", body: "{}" });
191
+ assert.equal(response.status, 404, target);
192
+ }
193
+ assert.equal((await fetch(`${adapter.baseUrl}/custom/responses`, { method: "POST", body: "{}" })).status, 404);
194
+ assert.equal(calls.length, 1);
195
+ // A stopped server: Codex shows the adapter's answer as the turn's error.
196
+ const down = await startCodexHistoryAdapter({ token: randomUUID(), fetchImpl: async () => { throw new TypeError("fetch failed"); } });
197
+ t.after(() => down.close());
198
+ const downUrl = (await prepareCodexHistory({ modelProvider: "custom", config }, null, { baseUrl: down.baseUrl }))
199
+ .config["model_providers.custom"].base_url;
200
+ const refused = await fetch(`${downUrl}/responses`, { method: "POST", body: JSON.stringify({ model: MODEL, input: [] }) });
201
+ assert.equal(refused.status, 502);
202
+ assert.equal((await refused.json()).error.message,
203
+ "Your model server at http://127.0.0.1:11434/v1 isn't answering. Start it, then send your message again.");
204
+ const untouched = { modelProvider: "custom", config: {} };
205
+ assert.equal(await prepareCodexHistory(untouched, null, { baseUrl: adapter.baseUrl }), untouched);
206
+ });
207
+
208
+ test("Health's reachability read asks only for the model list and counts any answer", async (t) => {
209
+ const stub = await startOllamaStub(t);
210
+ assert.equal(await probeCustomEndpoint({ engineId: "codex", baseUrl: `${stub.root}/v1` }), true);
211
+ assert.equal(await probeCustomEndpoint({ engineId: "claude", baseUrl: stub.root }), true);
212
+ assert.deepEqual(stub.requests.map(({ method, url }) => `${method} ${url}`), ["GET /v1/models", "GET /v1/models"]);
213
+ const closed = createServer();
214
+ await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve));
215
+ const { port } = closed.address();
216
+ await new Promise((resolve) => closed.close(resolve));
217
+ assert.equal(await probeCustomEndpoint({ engineId: "opencode", baseUrl: `http://127.0.0.1:${port}/v1` }), false);
218
+ });
@@ -0,0 +1,90 @@
1
+ {
2
+ "source": "Ollama 0.40.15 serving qwen3.5:4b-q4_K_M on 2026-11-06. Recorded: the Messages reply Claude Code 2.1.317 saved in its proof run, the chat completion and both /api/ps replies (read with curl before and after OLLAMA_CONTEXT_LENGTH=65536), and OpenCode 1.18.31's error once the server had stopped. Constructed: the Responses reply (the fields Codex 0.163.0 reads) and the model-missing 404 body.",
3
+ "model": "qwen3.5:4b-q4_K_M",
4
+ "messages": {
5
+ "id": "msg_ac8a55117ab9c4d3ec25734f",
6
+ "type": "message",
7
+ "role": "assistant",
8
+ "model": "qwen3.5:4b-q4_K_M",
9
+ "content": [
10
+ {
11
+ "type": "tool_use",
12
+ "id": "call_4wnyqz70",
13
+ "name": "Bash",
14
+ "input": {
15
+ "command": "cat greeting.txt && echo SHELL-OK"
16
+ }
17
+ }
18
+ ],
19
+ "stop_reason": "tool_use",
20
+ "usage": {
21
+ "input_tokens": 11114,
22
+ "output_tokens": 138
23
+ }
24
+ },
25
+ "chatCompletion": {
26
+ "id": "chatcmpl-128",
27
+ "object": "chat.completion",
28
+ "created": 1793950047,
29
+ "model": "qwen3.5:4b-q4_K_M",
30
+ "system_fingerprint": "fp_ollama",
31
+ "choices": [
32
+ {
33
+ "index": 0,
34
+ "message": {
35
+ "role": "assistant",
36
+ "content": "OK",
37
+ "reasoning": "Thinking Process:\n\n1. **Analyze the Request:**"
38
+ },
39
+ "finish_reason": "stop"
40
+ }
41
+ ]
42
+ },
43
+ "response": {
44
+ "id": "resp_128",
45
+ "object": "response",
46
+ "status": "completed",
47
+ "model": "qwen3.5:4b-q4_K_M",
48
+ "output": [
49
+ {
50
+ "type": "message",
51
+ "role": "assistant",
52
+ "content": [
53
+ {
54
+ "type": "output_text",
55
+ "text": "OK"
56
+ }
57
+ ]
58
+ }
59
+ ]
60
+ },
61
+ "psDefaultContext": {
62
+ "models": [
63
+ {
64
+ "name": "qwen3.5:4b-q4_K_M",
65
+ "model": "qwen3.5:4b-q4_K_M",
66
+ "size": 3085046905,
67
+ "size_vram": 3085046905,
68
+ "context_length": 4096
69
+ }
70
+ ]
71
+ },
72
+ "psRaisedContext": {
73
+ "models": [
74
+ {
75
+ "name": "qwen3.5:4b-q4_K_M",
76
+ "model": "qwen3.5:4b-q4_K_M",
77
+ "size": 4466021497,
78
+ "size_vram": 4466021497,
79
+ "context_length": 65536
80
+ }
81
+ ]
82
+ },
83
+ "modelMissing": {
84
+ "error": {
85
+ "message": "model 'qwen9:1b' not found",
86
+ "type": "not_found_error"
87
+ }
88
+ },
89
+ "openCodeServerDown": "Cannot connect to API: Unable to connect. Is the computer able to access the url?"
90
+ }