@jskit-ai/assistant-core 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -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/codexNotificationObserver.js +3 -3
- package/src/server/conversation/codexProcess.js +4 -2
- package/src/server/conversation/codexPromptDispatch.js +3 -3
- package/src/server/conversation/codexResultSettlement.js +2 -5
- package/src/server/conversation/codexRunJournal.js +55 -3
- package/src/server/conversation/codexThreadStatus.js +6 -2
- package/src/server/conversation/codexTurnEndings.js +3 -3
- package/src/server/conversation/codexTurnRecovery.js +16 -11
- package/src/server/conversation/codexTurnState.js +14 -1
- 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/codexTurn.test.js +76 -0
- package/test/customEndpoint.test.js +218 -0
- package/test/fixtures/customEndpoint/ollama-0.40.15.json +90 -0
|
@@ -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
|
+
};
|
package/test/codexTurn.test.js
CHANGED
|
@@ -1955,6 +1955,82 @@ for (const read of ["starting watchdog", "status check"]) {
|
|
|
1955
1955
|
});
|
|
1956
1956
|
}
|
|
1957
1957
|
|
|
1958
|
+
// A turn Codex has accepted and not yet reported; `sendTurn` runs before Codex's answer to turn/start.
|
|
1959
|
+
async function unstartedTurnFixture(t, { sendTurn } = {}) {
|
|
1960
|
+
const notices = [];
|
|
1961
|
+
const f = await receiptFixture({
|
|
1962
|
+
finalizingGraceMs: 30,
|
|
1963
|
+
outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); }
|
|
1964
|
+
});
|
|
1965
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
1966
|
+
const context = dispatchContext(f);
|
|
1967
|
+
f.codexStatus = "idle";
|
|
1968
|
+
f.notices = notices;
|
|
1969
|
+
f.started = { method: "turn/started", params: { threadId: f.threadId, turn: { id: f.turnId, status: "inProgress" } } };
|
|
1970
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", providerStatus: "", outerTurnId: context.messageId };
|
|
1971
|
+
f.provider.sendTurn = async () => {
|
|
1972
|
+
await sendTurn?.(f);
|
|
1973
|
+
return { id: f.turnId, status: "inProgress" };
|
|
1974
|
+
};
|
|
1975
|
+
f.provider.readThreadStatus = async () => ({ raw: { status: { type: f.codexStatus } } });
|
|
1976
|
+
f.history = [turn(f.turnId, "interrupted")];
|
|
1977
|
+
f.observe();
|
|
1978
|
+
assert.equal((await f.owner.startTurn(f.sessionId, {}, context)).providerFailure, "");
|
|
1979
|
+
assert.equal(f.run.state, "active");
|
|
1980
|
+
// Codex finishes the turn, and its turn/completed notification is lost: only its thread and history say so.
|
|
1981
|
+
f.finish = () => {
|
|
1982
|
+
f.codexStatus = "idle";
|
|
1983
|
+
f.history = [turn(f.turnId, "completed", "The complete reply.")];
|
|
1984
|
+
};
|
|
1985
|
+
return f;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
async function assertSettledWithReply(f) {
|
|
1989
|
+
await f.drain();
|
|
1990
|
+
assert.equal(f.run.state, "completed");
|
|
1991
|
+
assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["The complete reply."]);
|
|
1992
|
+
assert.deepEqual(f.notices, []);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// Codex usually answers turn/start before its turn/started notification, but may send turn/started first. Either way
|
|
1996
|
+
// Codex has reported the turn, so a thread status check after it finishes settles the turn with its answer.
|
|
1997
|
+
for (const order of ["after", "before"]) {
|
|
1998
|
+
test(`a turn Codex reports ${order} its turn/start answer is settled by a later idle status check`, async t => {
|
|
1999
|
+
const f = await unstartedTurnFixture(t, {
|
|
2000
|
+
sendTurn(fixture) { if (order === "before") fixture.emit(fixture.started); }
|
|
2001
|
+
});
|
|
2002
|
+
if (order === "after") f.emit(f.started);
|
|
2003
|
+
await f.drain();
|
|
2004
|
+
assert.equal(f.run.state, "active");
|
|
2005
|
+
f.finish();
|
|
2006
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
|
|
2007
|
+
await assertSettledWithReply(f);
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// When turn/started is missed, the mark does not outlive Codex's next report about the turn: a later notification
|
|
2012
|
+
// naming it, or Codex listing it as completed, settles it like any other turn. No timer is involved.
|
|
2013
|
+
for (const report of ["a later notification naming the turn", "the starting watchdog reading it as completed",
|
|
2014
|
+
"a status check reading it as completed"]) {
|
|
2015
|
+
test(`a delivered turn whose turn/started is missed is settled by ${report}`, async t => {
|
|
2016
|
+
const f = await unstartedTurnFixture(t);
|
|
2017
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
|
|
2018
|
+
await f.drain();
|
|
2019
|
+
assert.equal(f.run.state, "active", "Codex's idle thread and interrupted listing before it reports the turn do not end it");
|
|
2020
|
+
f.finish();
|
|
2021
|
+
if (report === "a later notification naming the turn") {
|
|
2022
|
+
f.emit({ method: "item/started", params: { threadId: f.threadId, turnId: f.turnId, item: { id: "thinking", type: "reasoning" } } });
|
|
2023
|
+
await f.drain();
|
|
2024
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
|
|
2025
|
+
} else if (report === "the starting watchdog reading it as completed") {
|
|
2026
|
+
await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false });
|
|
2027
|
+
} else {
|
|
2028
|
+
await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
|
|
2029
|
+
}
|
|
2030
|
+
await assertSettledWithReply(f);
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1958
2034
|
function controlContext(f, overrides = {}) {
|
|
1959
2035
|
return {
|
|
1960
2036
|
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
|
+
});
|