@jskit-ai/assistant-core 0.2.19 → 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 +7 -2
- package/examples/conversation/package.json +1 -1
- package/locales/en.json +1 -0
- package/package.json +6 -1
- package/src/server/conversation/claudeProcess.js +13 -6
- package/src/server/conversation/codexConfiguration.js +22 -4
- package/src/server/conversation/codexHistoryAdapter.js +49 -13
- package/src/server/conversation/codexProcess.js +4 -2
- package/src/server/conversation/customEndpoint.js +188 -0
- package/src/server/conversation/providers/claudeDriver.js +5 -4
- package/src/server/conversation/providers/codexDriverConnection.js +3 -3
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/server/hosts/customEndpoint.js +7 -0
- package/src/shared/nativeProviders.js +47 -1
- package/test/customEndpoint.test.js +218 -0
- package/test/fixtures/customEndpoint/ollama-0.40.15.json +90 -0
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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 =
|
|
135
|
+
const provider = connectionAiProvider(providerId, connection);
|
|
136
136
|
const definition = provider?.models.find(({ id }) => id === modelId);
|
|
137
|
-
if (provider
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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 (!
|
|
195
|
+
if (!historyRouted(destination) || !Array.isArray(body?.input) ||
|
|
174
196
|
!body.input.some((item) => item?.type === "compaction")) return body;
|
|
175
|
-
const model =
|
|
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 &&
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
if (config?.base_url !==
|
|
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) {
|
|
@@ -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
|
});
|
|
@@ -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 {
|
|
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 =
|
|
225
|
-
const model =
|
|
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,
|
|
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 {
|
|
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 =
|
|
201
|
-
const model =
|
|
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";
|
|
@@ -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 {
|
|
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
|
+
};
|
|
@@ -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
|
+
}
|