narraeon 0.1.0
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/LICENSE +201 -0
- package/README.md +164 -0
- package/README.zh-CN.md +164 -0
- package/dist/node/cli/NarraeonCli.js +163 -0
- package/dist/node/cli/main.js +38 -0
- package/dist/node/protocol/appPreferences.js +5 -0
- package/dist/node/protocol/contentTree.js +14 -0
- package/dist/node/protocol/modelConnections.js +110 -0
- package/dist/node/protocol/playerViews.js +1 -0
- package/dist/node/protocol/v1.js +278 -0
- package/dist/node/runtime/V1Runtime.js +625 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
- package/dist/node/runtime/config/appPaths.js +19 -0
- package/dist/node/runtime/content/ContentLimits.js +1 -0
- package/dist/node/runtime/content/ContentTreeFile.js +1 -0
- package/dist/node/runtime/content/ContentWorkspace.js +601 -0
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
- package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
- package/dist/node/runtime/model/AiFailureLog.js +332 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
- package/dist/node/runtime/model/ModelHost.js +89 -0
- package/dist/node/runtime/model/ModelHostStream.js +367 -0
- package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
- package/dist/node/runtime/model/ProviderStream.js +90 -0
- package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
- package/dist/node/runtime/play/PlayCallChain.js +1280 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
- package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
- package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
- package/dist/node/runtime/world/WorldStateRevision.js +75 -0
- package/dist/node/server/createRuntime.js +6 -0
- package/dist/node/server/createServer.js +218 -0
- package/dist/node/server/main.js +25 -0
- package/dist/node/server/startWebServer.js +28 -0
- package/dist/node/shared/default-play-prompts.js +85 -0
- package/dist/node/shared/default-preset-host.js +538 -0
- package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
- package/dist/node/shared/first-party-action-choices.js +164 -0
- package/dist/node/shared/first-party-generic-panels.js +105 -0
- package/dist/node/shared/first-party-play-preset-templates.js +41 -0
- package/dist/node/shared/first-party-player-view.js +179 -0
- package/dist/node/shared/play-preset-prompt-roles.js +4 -0
- package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
- package/dist/web/assets/index-DDx1itzT.js +1192 -0
- package/dist/web/index.html +14 -0
- package/package.json +86 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, stat, unlink, } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { modelProviderPresets, } from "../../protocol/modelConnections.js";
|
|
5
|
+
const libraryFileName = "model-connections-v1.json";
|
|
6
|
+
const maxModelListBytes = 1024 * 1024;
|
|
7
|
+
export class ModelConnectionUnavailableError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("No V1 model connection is enabled");
|
|
10
|
+
this.name = "ModelConnectionUnavailableError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class ModelConnectionStore {
|
|
14
|
+
#configRoot;
|
|
15
|
+
#path;
|
|
16
|
+
#fetch;
|
|
17
|
+
#mutationTail = Promise.resolve();
|
|
18
|
+
constructor(configRoot, fetchImplementation = fetch) {
|
|
19
|
+
this.#configRoot = configRoot;
|
|
20
|
+
this.#path = join(configRoot, libraryFileName);
|
|
21
|
+
this.#fetch = fetchImplementation;
|
|
22
|
+
}
|
|
23
|
+
async view() {
|
|
24
|
+
await this.#mutationTail;
|
|
25
|
+
const document = await this.#readDocument();
|
|
26
|
+
return this.#viewDocument(document);
|
|
27
|
+
}
|
|
28
|
+
save(input) {
|
|
29
|
+
return this.#mutate(async () => {
|
|
30
|
+
const document = await this.#readDocument();
|
|
31
|
+
const index = input.connectionId === undefined
|
|
32
|
+
? -1
|
|
33
|
+
: document.connections.findIndex(({ id }) => id === input.connectionId);
|
|
34
|
+
if (input.connectionId !== undefined && index < 0)
|
|
35
|
+
throw new Error("The model configuration to edit was not found");
|
|
36
|
+
const existing = index < 0 ? undefined : document.connections[index];
|
|
37
|
+
const saved = {
|
|
38
|
+
id: existing?.id ?? randomUUID(),
|
|
39
|
+
...normalizeSaveInput(input, existing),
|
|
40
|
+
};
|
|
41
|
+
if (index < 0)
|
|
42
|
+
document.connections.push(saved);
|
|
43
|
+
else
|
|
44
|
+
document.connections[index] = saved;
|
|
45
|
+
document.activeConnectionId = saved.id;
|
|
46
|
+
await this.#writeDocument(document);
|
|
47
|
+
return this.#viewDocument(document);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
select(connectionId) {
|
|
51
|
+
return this.#mutate(async () => {
|
|
52
|
+
const document = await this.#readDocument();
|
|
53
|
+
if (!document.connections.some(({ id }) => id === connectionId))
|
|
54
|
+
throw new Error("The model configuration to enable was not found");
|
|
55
|
+
document.activeConnectionId = connectionId;
|
|
56
|
+
await this.#writeDocument(document);
|
|
57
|
+
return this.#viewDocument(document);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
delete(connectionId) {
|
|
61
|
+
return this.#mutate(async () => {
|
|
62
|
+
const document = await this.#readDocument();
|
|
63
|
+
const index = document.connections.findIndex(({ id }) => id === connectionId);
|
|
64
|
+
if (index < 0)
|
|
65
|
+
throw new Error("The model configuration to delete was not found");
|
|
66
|
+
if (document.activeConnectionId === connectionId &&
|
|
67
|
+
document.connections.length > 1)
|
|
68
|
+
throw new Error("Switch to another model configuration before deleting the current one");
|
|
69
|
+
document.connections.splice(index, 1);
|
|
70
|
+
if (document.activeConnectionId === connectionId)
|
|
71
|
+
document.activeConnectionId = null;
|
|
72
|
+
await this.#writeDocument(document);
|
|
73
|
+
return this.#viewDocument(document);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async bind() {
|
|
77
|
+
await this.#mutationTail;
|
|
78
|
+
const document = await this.#readDocument();
|
|
79
|
+
const connection = document.connections.find(({ id }) => id === document.activeConnectionId);
|
|
80
|
+
if (connection === undefined)
|
|
81
|
+
throw new ModelConnectionUnavailableError();
|
|
82
|
+
return toBinding(connection);
|
|
83
|
+
}
|
|
84
|
+
async listModels(input) {
|
|
85
|
+
await this.#mutationTail;
|
|
86
|
+
const provider = validateProvider(input.provider);
|
|
87
|
+
const baseUrl = normalizeBaseUrl(requiredTrimmed(input.baseUrl, "Base URL", 4096));
|
|
88
|
+
let apiKey = normalizeOptionalApiKey(input.apiKey);
|
|
89
|
+
if (apiKey === undefined && input.connectionId !== undefined) {
|
|
90
|
+
const stored = (await this.#readDocument()).connections.find(({ id }) => id === input.connectionId);
|
|
91
|
+
if (stored === undefined)
|
|
92
|
+
throw new Error("Model configuration not found");
|
|
93
|
+
if (stored.provider !== provider || stored.baseUrl !== baseUrl)
|
|
94
|
+
throw new Error("The endpoint or protocol changed; enter the API key again before fetching models");
|
|
95
|
+
apiKey = stored.apiKey;
|
|
96
|
+
}
|
|
97
|
+
if (apiKey === undefined)
|
|
98
|
+
throw new Error("Fetching the model list requires an API key");
|
|
99
|
+
const url = providerUrl(baseUrl, "models");
|
|
100
|
+
if (provider === "anthropic_messages")
|
|
101
|
+
url.searchParams.set("limit", "1000");
|
|
102
|
+
const response = await this.#fetch(url, {
|
|
103
|
+
method: "GET",
|
|
104
|
+
headers: provider === "anthropic_messages"
|
|
105
|
+
? {
|
|
106
|
+
"anthropic-version": "2023-06-01",
|
|
107
|
+
"x-api-key": apiKey,
|
|
108
|
+
}
|
|
109
|
+
: { Authorization: `Bearer ${apiKey}` },
|
|
110
|
+
signal: AbortSignal.timeout(15_000),
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
throw await providerListError(response);
|
|
114
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
115
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxModelListBytes)
|
|
116
|
+
throw new Error("Provider model-list response exceeds the 1 MiB limit");
|
|
117
|
+
const source = await response.text();
|
|
118
|
+
if (Buffer.byteLength(source, "utf8") > maxModelListBytes)
|
|
119
|
+
throw new Error("Provider model-list response exceeds the 1 MiB limit");
|
|
120
|
+
let payload;
|
|
121
|
+
try {
|
|
122
|
+
payload = JSON.parse(source);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error("Provider model-list response is not valid JSON");
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(payload) || !Array.isArray(payload.data))
|
|
128
|
+
throw new Error("Provider model-list response is missing the data array");
|
|
129
|
+
const models = [
|
|
130
|
+
...new Set(payload.data.flatMap((item) => {
|
|
131
|
+
if (!isRecord(item) || typeof item.id !== "string")
|
|
132
|
+
return [];
|
|
133
|
+
const id = item.id.trim();
|
|
134
|
+
return id.length > 0 && id.length <= 512 ? [id] : [];
|
|
135
|
+
})),
|
|
136
|
+
]
|
|
137
|
+
.sort((left, right) => left.localeCompare(right))
|
|
138
|
+
.slice(0, 2_000);
|
|
139
|
+
if (models.length === 0)
|
|
140
|
+
throw new Error("Provider model list has no usable model IDs");
|
|
141
|
+
return { models };
|
|
142
|
+
}
|
|
143
|
+
#viewDocument(document) {
|
|
144
|
+
return {
|
|
145
|
+
configured: document.activeConnectionId !== null,
|
|
146
|
+
activeConnectionId: document.activeConnectionId,
|
|
147
|
+
connections: document.connections.map(toView),
|
|
148
|
+
presets: structuredClone(modelProviderPresets),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async #mutate(operation) {
|
|
152
|
+
const previous = this.#mutationTail;
|
|
153
|
+
let release = () => undefined;
|
|
154
|
+
this.#mutationTail = new Promise((resolve) => {
|
|
155
|
+
release = resolve;
|
|
156
|
+
});
|
|
157
|
+
await previous;
|
|
158
|
+
try {
|
|
159
|
+
return await operation();
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
release();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async #readDocument() {
|
|
166
|
+
try {
|
|
167
|
+
const info = await stat(this.#path);
|
|
168
|
+
if (!info.isFile())
|
|
169
|
+
throw new Error("Model-connection configuration path is not a regular file");
|
|
170
|
+
verifyPrivateMode(info.mode);
|
|
171
|
+
return validateDocument(JSON.parse(await readFile(this.#path, "utf8")));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (!isMissingFile(error))
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
return { schemaVersion: 1, activeConnectionId: null, connections: [] };
|
|
178
|
+
}
|
|
179
|
+
async #writeDocument(document) {
|
|
180
|
+
await mkdir(this.#configRoot, { recursive: true, mode: 0o700 });
|
|
181
|
+
const temporary = join(this.#configRoot, `.${libraryFileName}.${randomUUID()}.tmp`);
|
|
182
|
+
let handle;
|
|
183
|
+
try {
|
|
184
|
+
handle = await open(temporary, "wx", 0o600);
|
|
185
|
+
await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, "utf8");
|
|
186
|
+
await handle.sync();
|
|
187
|
+
await handle.close();
|
|
188
|
+
handle = undefined;
|
|
189
|
+
await chmod(temporary, 0o600);
|
|
190
|
+
await rename(temporary, this.#path);
|
|
191
|
+
await chmod(this.#path, 0o600);
|
|
192
|
+
verifyPrivateMode((await stat(this.#path)).mode);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
await handle?.close().catch(() => undefined);
|
|
196
|
+
await unlink(temporary).catch(() => undefined);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function normalizeSaveInput(input, existing) {
|
|
202
|
+
const name = requiredTrimmed(input.name, "Configuration name", 160);
|
|
203
|
+
const presetId = validatePresetId(input.presetId);
|
|
204
|
+
const provider = validateProvider(input.provider);
|
|
205
|
+
const baseUrl = normalizeBaseUrl(requiredTrimmed(input.baseUrl, "Base URL", 4096));
|
|
206
|
+
const modelId = requiredTrimmed(input.modelId, "Model ID", 512);
|
|
207
|
+
const explicitApiKey = normalizeOptionalApiKey(input.apiKey);
|
|
208
|
+
const apiKey = explicitApiKey ??
|
|
209
|
+
(existing?.provider === provider && existing.baseUrl === baseUrl
|
|
210
|
+
? existing.apiKey
|
|
211
|
+
: undefined);
|
|
212
|
+
if (apiKey === undefined)
|
|
213
|
+
throw new Error(existing === undefined
|
|
214
|
+
? "A new model configuration requires an API key"
|
|
215
|
+
: "The endpoint or protocol changed; enter the API key again because old credentials are never forwarded to a new endpoint");
|
|
216
|
+
validateTokenLimits(input.contextWindowTokens, input.maxOutputTokens);
|
|
217
|
+
validatePresetBinding(presetId, provider, baseUrl);
|
|
218
|
+
return {
|
|
219
|
+
name,
|
|
220
|
+
presetId,
|
|
221
|
+
provider,
|
|
222
|
+
baseUrl,
|
|
223
|
+
apiKey,
|
|
224
|
+
modelId,
|
|
225
|
+
contextWindowTokens: input.contextWindowTokens,
|
|
226
|
+
maxOutputTokens: input.maxOutputTokens,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function validateDocument(value) {
|
|
230
|
+
if (!isRecord(value) ||
|
|
231
|
+
value.schemaVersion !== 1 ||
|
|
232
|
+
(value.activeConnectionId !== null &&
|
|
233
|
+
typeof value.activeConnectionId !== "string") ||
|
|
234
|
+
!Array.isArray(value.connections) ||
|
|
235
|
+
value.connections.length > 64)
|
|
236
|
+
throw new Error("Model-connection configuration file does not match its fixed schema");
|
|
237
|
+
const connections = value.connections.map(validateStoredConnection);
|
|
238
|
+
if (new Set(connections.map(({ id }) => id)).size !== connections.length)
|
|
239
|
+
throw new Error("Model-connection configuration contains duplicate IDs");
|
|
240
|
+
if (value.activeConnectionId !== null &&
|
|
241
|
+
!connections.some(({ id }) => id === value.activeConnectionId))
|
|
242
|
+
throw new Error("The enabled model connection does not exist");
|
|
243
|
+
return {
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
activeConnectionId: value.activeConnectionId,
|
|
246
|
+
connections,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function validateStoredConnection(value) {
|
|
250
|
+
if (!isRecord(value))
|
|
251
|
+
throw new Error("Model connection must be an object");
|
|
252
|
+
const id = requiredTrimmed(value.id, "Model connection ID", 160);
|
|
253
|
+
const name = requiredTrimmed(value.name, "Configuration name", 160);
|
|
254
|
+
const presetId = validatePresetId(value.presetId);
|
|
255
|
+
const provider = validateProvider(value.provider);
|
|
256
|
+
const baseUrl = normalizeBaseUrl(requiredTrimmed(value.baseUrl, "Base URL"));
|
|
257
|
+
const apiKey = requiredTrimmed(value.apiKey, "API Key", 16_384);
|
|
258
|
+
const modelId = requiredTrimmed(value.modelId, "Model ID", 512);
|
|
259
|
+
validateTokenLimits(value.contextWindowTokens, value.maxOutputTokens);
|
|
260
|
+
validatePresetBinding(presetId, provider, baseUrl);
|
|
261
|
+
return {
|
|
262
|
+
id,
|
|
263
|
+
name,
|
|
264
|
+
presetId,
|
|
265
|
+
provider,
|
|
266
|
+
baseUrl,
|
|
267
|
+
apiKey,
|
|
268
|
+
modelId,
|
|
269
|
+
contextWindowTokens: value.contextWindowTokens,
|
|
270
|
+
maxOutputTokens: value.maxOutputTokens,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function toView(connection) {
|
|
274
|
+
return structuredClone({
|
|
275
|
+
id: connection.id,
|
|
276
|
+
name: connection.name,
|
|
277
|
+
presetId: connection.presetId,
|
|
278
|
+
provider: connection.provider,
|
|
279
|
+
baseUrl: connection.baseUrl,
|
|
280
|
+
modelId: connection.modelId,
|
|
281
|
+
contextWindowTokens: connection.contextWindowTokens,
|
|
282
|
+
maxOutputTokens: connection.maxOutputTokens,
|
|
283
|
+
hasApiKey: true,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function toBinding(connection) {
|
|
287
|
+
return structuredClone({
|
|
288
|
+
provider: connection.provider,
|
|
289
|
+
baseUrl: connection.baseUrl,
|
|
290
|
+
apiKey: connection.apiKey,
|
|
291
|
+
modelId: connection.modelId,
|
|
292
|
+
contextWindowTokens: connection.contextWindowTokens,
|
|
293
|
+
maxOutputTokens: connection.maxOutputTokens,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function validateProvider(value) {
|
|
297
|
+
if (value !== "chat_completions" &&
|
|
298
|
+
value !== "openai_responses" &&
|
|
299
|
+
value !== "anthropic_messages")
|
|
300
|
+
throw new Error("Model-connection protocol is invalid");
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
function validatePresetId(value) {
|
|
304
|
+
if (typeof value !== "string" ||
|
|
305
|
+
!modelProviderPresets.some(({ id }) => id === value))
|
|
306
|
+
throw new Error("Model-provider preset is invalid");
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
function validatePresetBinding(presetId, provider, baseUrl) {
|
|
310
|
+
const preset = modelProviderPresets.find(({ id }) => id === presetId);
|
|
311
|
+
if (preset.id !== "custom" &&
|
|
312
|
+
(preset.provider !== provider ||
|
|
313
|
+
normalizeBaseUrl(preset.baseUrl) !== baseUrl))
|
|
314
|
+
throw new Error("The endpoint or protocol does not match the built-in provider; select Custom endpoint for a custom configuration");
|
|
315
|
+
}
|
|
316
|
+
function normalizeBaseUrl(value) {
|
|
317
|
+
let url;
|
|
318
|
+
try {
|
|
319
|
+
url = new URL(value);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new Error("Base URL must be absolute");
|
|
323
|
+
}
|
|
324
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") ||
|
|
325
|
+
url.username.length > 0 ||
|
|
326
|
+
url.password.length > 0 ||
|
|
327
|
+
url.search.length > 0 ||
|
|
328
|
+
url.hash.length > 0)
|
|
329
|
+
throw new Error("Base URL must be an HTTP(S) address without credentials, query, or fragment");
|
|
330
|
+
url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
331
|
+
return url.toString().replace(/\/$/u, "");
|
|
332
|
+
}
|
|
333
|
+
function normalizeOptionalApiKey(value) {
|
|
334
|
+
if (value === undefined || value === "")
|
|
335
|
+
return undefined;
|
|
336
|
+
return requiredTrimmed(value, "API Key", 16_384);
|
|
337
|
+
}
|
|
338
|
+
function requiredTrimmed(value, label, max = 4096) {
|
|
339
|
+
if (typeof value !== "string")
|
|
340
|
+
throw new Error(`${label} must be a string`);
|
|
341
|
+
const normalized = value.trim();
|
|
342
|
+
if (normalized.length === 0 ||
|
|
343
|
+
normalized.length > max ||
|
|
344
|
+
normalized.includes("\r") ||
|
|
345
|
+
normalized.includes("\n"))
|
|
346
|
+
throw new Error(`${label} is invalid`);
|
|
347
|
+
return normalized;
|
|
348
|
+
}
|
|
349
|
+
function validateTokenLimits(context, output) {
|
|
350
|
+
if (!Number.isSafeInteger(context) ||
|
|
351
|
+
context < 4_096 ||
|
|
352
|
+
context > 16_777_216 ||
|
|
353
|
+
!Number.isSafeInteger(output) ||
|
|
354
|
+
output < 256 ||
|
|
355
|
+
output >= context)
|
|
356
|
+
throw new Error("Actual model context window or maximum output is invalid");
|
|
357
|
+
}
|
|
358
|
+
function providerUrl(baseUrl, resource) {
|
|
359
|
+
const url = new URL(baseUrl);
|
|
360
|
+
const suffix = `/${resource}`;
|
|
361
|
+
const path = url.pathname.replace(/\/+$/u, "");
|
|
362
|
+
url.pathname = path.endsWith(suffix) ? path : `${path}${suffix}`;
|
|
363
|
+
return url;
|
|
364
|
+
}
|
|
365
|
+
async function providerListError(response) {
|
|
366
|
+
const text = (await response.text()).slice(0, 4096);
|
|
367
|
+
return new Error(`Failed to fetch model list: ${response.status} ${text}`);
|
|
368
|
+
}
|
|
369
|
+
function verifyPrivateMode(mode) {
|
|
370
|
+
if ((mode & 0o077) !== 0)
|
|
371
|
+
throw new Error("Model-connection configuration permissions are too broad; only the current user may read it");
|
|
372
|
+
}
|
|
373
|
+
function isMissingFile(error) {
|
|
374
|
+
return error.code === "ENOENT";
|
|
375
|
+
}
|
|
376
|
+
function isRecord(value) {
|
|
377
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
378
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export function equalModelHostBinding(left, right) {
|
|
2
|
+
return (left.provider === right.provider &&
|
|
3
|
+
left.endpointFingerprint === right.endpointFingerprint &&
|
|
4
|
+
left.modelId === right.modelId &&
|
|
5
|
+
left.contextWindowTokens === right.contextWindowTokens &&
|
|
6
|
+
left.maxOutputTokens === right.maxOutputTokens &&
|
|
7
|
+
left.protocolConfigFingerprint === right.protocolConfigFingerprint);
|
|
8
|
+
}
|
|
9
|
+
/** The persisted operation binding is unavailable in the current Runtime. */
|
|
10
|
+
export class ModelHostBindingMismatchError extends Error {
|
|
11
|
+
kind = "binding_mismatch";
|
|
12
|
+
constructor(message, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = "ModelHostBindingMismatchError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A deterministic provider failure: the operation may be failed safely. */
|
|
18
|
+
export class ModelHostFailureError extends Error {
|
|
19
|
+
kind = "failed";
|
|
20
|
+
constructor(message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "ModelHostFailureError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The provider outcome cannot be replayed without risking a duplicate response. */
|
|
26
|
+
export class ModelHostOutcomeUnknownError extends Error {
|
|
27
|
+
kind = "unknown";
|
|
28
|
+
constructor(message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "ModelHostOutcomeUnknownError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Deterministic adapter for exercising the true external ModelHost port. */
|
|
34
|
+
export class ScriptedModelHost {
|
|
35
|
+
requests = [];
|
|
36
|
+
#binding;
|
|
37
|
+
#steps;
|
|
38
|
+
#responseIndex = 0;
|
|
39
|
+
constructor(input) {
|
|
40
|
+
this.#binding = structuredClone(input.binding);
|
|
41
|
+
this.#steps = structuredClone(input.steps);
|
|
42
|
+
}
|
|
43
|
+
binding() {
|
|
44
|
+
return structuredClone(this.#binding);
|
|
45
|
+
}
|
|
46
|
+
exchange(request, observer) {
|
|
47
|
+
this.requests.push(structuredClone(request));
|
|
48
|
+
const step = this.#steps.shift();
|
|
49
|
+
if (step === undefined)
|
|
50
|
+
return Promise.reject(new Error("scripted ModelHost steps exhausted"));
|
|
51
|
+
if (step.outcome === "failure")
|
|
52
|
+
return Promise.reject(new ModelHostFailureError(step.message));
|
|
53
|
+
if (step.outcome === "unknown")
|
|
54
|
+
return Promise.reject(new ModelHostOutcomeUnknownError(step.message));
|
|
55
|
+
const { outcome: _outcome, continuation, deltas, ...response } = step;
|
|
56
|
+
void _outcome;
|
|
57
|
+
for (const delta of deltas ?? [])
|
|
58
|
+
observer?.onDelta?.(structuredClone(delta));
|
|
59
|
+
const providerState = response.providerState ??
|
|
60
|
+
(continuation === "available"
|
|
61
|
+
? scriptedProviderState(this.#binding.provider, this.#responseIndex)
|
|
62
|
+
: undefined);
|
|
63
|
+
this.#responseIndex += 1;
|
|
64
|
+
return Promise.resolve({
|
|
65
|
+
...structuredClone(response),
|
|
66
|
+
...(providerState === undefined
|
|
67
|
+
? {}
|
|
68
|
+
: { providerState: structuredClone(providerState) }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function scriptedProviderState(provider, responseIndex) {
|
|
73
|
+
if (provider === "openai_responses")
|
|
74
|
+
return {
|
|
75
|
+
protocol: "openai_responses",
|
|
76
|
+
output: [],
|
|
77
|
+
responseId: `scripted-${responseIndex}`,
|
|
78
|
+
};
|
|
79
|
+
if (provider === "anthropic_messages")
|
|
80
|
+
return {
|
|
81
|
+
protocol: "anthropic_messages",
|
|
82
|
+
content: [],
|
|
83
|
+
responseId: `scripted-${responseIndex}`,
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
protocol: "chat_completions",
|
|
87
|
+
assistantMessage: { role: "assistant", content: null },
|
|
88
|
+
};
|
|
89
|
+
}
|