@zq-silk/yui 2.7.1 → 2.8.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/dist/cli/commandCatalog.js +8 -7
- package/dist/cli/updateCommand.js +3 -0
- package/dist/commands/configCommands.js +1 -0
- package/dist/commands/globalRoleCommands.js +42 -35
- package/dist/commands/inboxCommands.js +60 -40
- package/dist/commands/taskCommands.js +7 -3
- package/dist/commands/taskRoleRuntimeStatus.js +2 -0
- package/dist/context/runContextPack.js +48 -3
- package/dist/context/taskContext.js +36 -12
- package/dist/controlPlaneCli.js +46 -12
- package/dist/controller/controller.js +5 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +9 -4
- package/dist/controller/globalInputDelivery.js +17 -5
- package/dist/controller/globalRuntimeAttention.js +135 -5
- package/dist/controller/runtime.js +4 -0
- package/dist/controller/structuredProviderObservation.js +51 -15
- package/dist/controller/taskHostTerminalRecovery.js +33 -0
- package/dist/executor/fileRoleLaunchPlanner.js +4 -9
- package/dist/message/inboxReceipt.js +30 -0
- package/dist/message/taskInboxOwnership.js +25 -3
- package/dist/models/modelManagement.js +469 -0
- package/dist/models/modelManagementConfig.js +46 -0
- package/dist/models/modelManagementFiles.js +90 -0
- package/dist/models/modelManagementHttp.js +90 -0
- package/dist/models/modelManagementTypes.js +1 -0
- package/dist/output/boundedRead.js +43 -14
- package/dist/runtime/agentEndpointIdentity.js +8 -4
- package/dist/runtime/agentEndpointOwnership.js +5 -6
- package/dist/runtime/agentHost.js +62 -10
- package/dist/runtime/agentHostImplementation.js +18 -0
- package/dist/runtime/managedCaller.js +25 -1
- package/dist/runtime/managedIdentity.js +1 -1
- package/dist/runtime/providerRetry.js +19 -1
- package/dist/runtime/sessionCaller.js +53 -0
- package/dist/storage/contextRecords.js +28 -0
- package/dist/storage/sqliteSchema.js +10 -0
- package/dist/storage/sqliteStore.js +9 -5
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskStore.js +4 -0
- package/dist/task/completionReadiness.js +3 -3
- package/dist/web/assets/assetManifest.js +28 -2
- package/dist/web/assets/client/app/manage.js +6 -0
- package/dist/web/assets/client/app/models.js +20 -0
- package/dist/web/assets/client/app/settings.js +41 -9
- package/dist/web/assets/client/i18n/en.js +180 -2
- package/dist/web/assets/client/i18n/zh-CN.js +178 -2
- package/dist/web/assets/client/index.js +8 -0
- package/dist/web/assets/client/layout/manageShell.js +94 -0
- package/dist/web/assets/client/lib/api.js +13 -1
- package/dist/web/assets/client/lib/i18n.js +5 -3
- package/dist/web/assets/client/views/dock/conversation.js +231 -80
- package/dist/web/assets/client/views/models.js +777 -0
- package/dist/web/assets/client/views/skills.js +22 -15
- package/dist/web/assets/shared/icons.js +2 -0
- package/dist/web/assets/shared/manageNav.js +13 -0
- package/dist/web/assets/shell/manage.js +54 -0
- package/dist/web/assets/shell/models.js +7 -0
- package/dist/web/assets/shell/settings.js +1 -35
- package/dist/web/assets/shell/settingsContent.js +30 -0
- package/dist/web/assets/shell/sidebar.js +1 -0
- package/dist/web/assets/styles/index.js +6 -0
- package/dist/web/assets/styles/views/conversation.js +133 -0
- package/dist/web/assets/styles/views/manage.js +71 -0
- package/dist/web/assets/styles/views/models.js +129 -0
- package/dist/web/assets/styles/views/settings.js +28 -14
- package/dist/web/assets/styles/views/sidebar.js +1 -0
- package/dist/web/webServer.js +59 -9
- package/docs/cli-information-contract.md +42 -11
- package/docs/cli-information-contract.zh-CN.md +28 -7
- package/docs/managed-turn-and-session-runtime.md +47 -1
- package/docs/managed-turn-and-session-runtime.zh-CN.md +32 -0
- package/docs/release-workflow.md +58 -7
- package/docs/release-workflow.zh-CN.md +39 -4
- package/docs/storage-baseline.md +6 -1
- package/docs/storage-baseline.zh-CN.md +4 -1
- package/docs/testing/review-evaluation.md +148 -0
- package/docs/testing/review-evaluation.zh-CN.md +117 -0
- package/docs/testing/verification-levels.md +6 -2
- package/docs/testing/verification-levels.zh-CN.md +3 -0
- package/package.json +1 -1
- package/skills/yui-leader/VERSION +1 -1
- package/skills/yui-leader/references/execution.md +29 -0
- package/skills/yui-leader/references/replicated-execution.md +7 -0
- package/skills/yui-operator/SKILL.md +5 -1
- package/skills/yui-operator/VERSION +1 -1
- package/skills/yui-reviewer/SKILL.md +35 -0
- package/skills/yui-reviewer/VERSION +1 -1
- package/skills/yui-runtime/SKILL.md +53 -7
- package/skills/yui-runtime/VERSION +1 -1
- package/skills/yui-runtime/references/recovery.md +24 -5
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { webLocalMutation, WebRequestRejected } from "../web/webMutation.js";
|
|
4
|
+
import { modelFields, modelObject, modelUrl, validateModelConnection } from "./modelManagementConfig.js";
|
|
5
|
+
import { modelDigest, modelJsonFile, modelKey, newModelFile, replaceModelFile } from "./modelManagementFiles.js";
|
|
6
|
+
import { readModelJson } from "./modelManagementHttp.js";
|
|
7
|
+
const NOTICE = "Configuration is owned by metabridge. Saved does not mean applied: reload through your existing deployment flow if needed, then refresh the catalog. No Session, account or model is switched; inference is not validated.";
|
|
8
|
+
const empty = () => ({
|
|
9
|
+
status: "not-queried", attemptedAt: null, observedAt: null, stale: false, error: null, items: []
|
|
10
|
+
});
|
|
11
|
+
export function createModelManagement(store, options = {}) {
|
|
12
|
+
const request = options.request ?? readModelJson;
|
|
13
|
+
const now = options.now ?? (() => new Date());
|
|
14
|
+
let catalog = empty();
|
|
15
|
+
const quotas = { factory: empty(), "command-code": empty() };
|
|
16
|
+
let cacheRevision = "";
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
function snapshot() {
|
|
19
|
+
const connection = store.getConfig().modelManagement ?? null;
|
|
20
|
+
let root;
|
|
21
|
+
let providers = [];
|
|
22
|
+
let configurationError = null;
|
|
23
|
+
try {
|
|
24
|
+
if (connection?.bridgeConfigFile) {
|
|
25
|
+
root = modelJsonFile(connection.bridgeConfigFile);
|
|
26
|
+
if (root.value.providerFiles !== undefined && root.value.backends !== undefined)
|
|
27
|
+
throw new Error();
|
|
28
|
+
if (root.value.providerFiles !== undefined) {
|
|
29
|
+
if (!Array.isArray(root.value.providerFiles) || !root.value.providerFiles.length || root.value.providerFiles.length > 100)
|
|
30
|
+
throw new Error();
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
let bytes = Buffer.byteLength(root.raw);
|
|
33
|
+
providers = root.value.providerFiles.map((path) => {
|
|
34
|
+
if (typeof path !== "string")
|
|
35
|
+
throw new Error();
|
|
36
|
+
const filename = path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : resolve(dirname(root.path), path);
|
|
37
|
+
if (seen.has(filename))
|
|
38
|
+
throw new Error();
|
|
39
|
+
seen.add(filename);
|
|
40
|
+
const file = modelJsonFile(filename);
|
|
41
|
+
bytes += Buffer.byteLength(file.raw);
|
|
42
|
+
if (bytes > 2 * 1024 * 1024)
|
|
43
|
+
throw new Error();
|
|
44
|
+
return { file, value: file.value, id: modelDigest(filename) };
|
|
45
|
+
});
|
|
46
|
+
if (new Set(providers.map(p => p.value.name)).size !== providers.length)
|
|
47
|
+
throw new Error();
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const backends = root.value.backends ?? [];
|
|
51
|
+
if (!Array.isArray(backends) || backends.length > 100)
|
|
52
|
+
throw new Error();
|
|
53
|
+
const names = new Set();
|
|
54
|
+
providers = backends.map((value, index) => {
|
|
55
|
+
const row = modelObject(value);
|
|
56
|
+
const name = typeof row.name === "string" ? row.name : String(index);
|
|
57
|
+
if (names.has(name))
|
|
58
|
+
throw new Error();
|
|
59
|
+
names.add(name);
|
|
60
|
+
return { file: root, value: row, id: modelDigest([root.path, name]) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
configurationError = "Bridge configuration is unavailable, ambiguous or unsupported. No configuration was changed.";
|
|
67
|
+
providers = [];
|
|
68
|
+
}
|
|
69
|
+
const revision = modelDigest([connection, root?.raw ?? null, providers.map(p => [p.id, p.file.raw]), configurationError]);
|
|
70
|
+
if (revision !== cacheRevision) {
|
|
71
|
+
catalog = empty();
|
|
72
|
+
quotas.factory = empty();
|
|
73
|
+
quotas["command-code"] = empty();
|
|
74
|
+
cacheRevision = revision;
|
|
75
|
+
}
|
|
76
|
+
return { connection, root, providers, configurationError, revision };
|
|
77
|
+
}
|
|
78
|
+
function read() {
|
|
79
|
+
const state = snapshot();
|
|
80
|
+
return {
|
|
81
|
+
revision: state.revision, connection: state.connection, providers: state.providers.map(providerView),
|
|
82
|
+
configurationError: state.configurationError, application: "unverified", notice: NOTICE
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function checkRevision(body) {
|
|
86
|
+
const state = snapshot();
|
|
87
|
+
if (typeof body.revision !== "string" || body.revision !== state.revision) {
|
|
88
|
+
throw new WebRequestRejected("Model configuration changed; read it again before submitting.");
|
|
89
|
+
}
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
function saveConnection(input) {
|
|
93
|
+
const body = modelObject(input);
|
|
94
|
+
modelFields(body, ["revision", "connection"]);
|
|
95
|
+
const state = checkRevision(body);
|
|
96
|
+
const connection = body.connection === null ? null : validateModelConnection(body.connection);
|
|
97
|
+
if (connection && state.connection) {
|
|
98
|
+
for (const [address, credential] of [
|
|
99
|
+
["bridgeUrl", "catalogKeyFile"], ["cpaUrl", "managementKeyFile"]
|
|
100
|
+
]) {
|
|
101
|
+
const previous = state.connection;
|
|
102
|
+
const origin = (url) => url ? modelUrl(url).origin : "";
|
|
103
|
+
if (previous[credential] && previous[credential] === connection[credential]
|
|
104
|
+
&& origin(previous[address]) !== origin(connection[address])) {
|
|
105
|
+
throw new WebRequestRejected(`Changing ${address} origin requires a new ${credential} reference, or clear the old reference and save before explicitly binding it again.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// No external I/O inside the store transaction.
|
|
110
|
+
webLocalMutation(store, tx => {
|
|
111
|
+
if (modelDigest(tx.getConfig().modelManagement ?? null) !== modelDigest(state.connection)) {
|
|
112
|
+
throw new WebRequestRejected("Connection changed; read it again.");
|
|
113
|
+
}
|
|
114
|
+
const config = { ...tx.getConfig() };
|
|
115
|
+
if (connection)
|
|
116
|
+
config.modelManagement = connection;
|
|
117
|
+
else
|
|
118
|
+
delete config.modelManagement;
|
|
119
|
+
tx.saveConfig(config);
|
|
120
|
+
});
|
|
121
|
+
return read();
|
|
122
|
+
}
|
|
123
|
+
function saveProvider(input) {
|
|
124
|
+
const body = modelObject(input);
|
|
125
|
+
modelFields(body, ["revision", "operation", "provider", "id"]);
|
|
126
|
+
const state = checkRevision(body);
|
|
127
|
+
if (!state.root || state.configurationError)
|
|
128
|
+
throw new WebRequestRejected("Select a readable local bridge configuration first.");
|
|
129
|
+
const removing = body.operation === "remove";
|
|
130
|
+
if (!removing && body.operation !== "save")
|
|
131
|
+
throw new WebRequestRejected("Unknown provider operation.");
|
|
132
|
+
const draft = removing ? null : modelObject(body.provider);
|
|
133
|
+
if (draft)
|
|
134
|
+
modelFields(draft, ["id", "name", "via", "baseUrl", "prefix", "enabled", "apiKey"]);
|
|
135
|
+
const id = removing ? body.id : draft.id;
|
|
136
|
+
const target = id === undefined ? undefined : state.providers.find(p => p.id === id);
|
|
137
|
+
if (id !== undefined && !target)
|
|
138
|
+
throw new WebRequestRejected("Provider identity changed; read it again.");
|
|
139
|
+
if (target && !providerView(target).editable)
|
|
140
|
+
throw new WebRequestRejected("This provider is read-only.");
|
|
141
|
+
if (removing && !target)
|
|
142
|
+
throw new WebRequestRejected("Select the exact provider to remove.");
|
|
143
|
+
if (!removing && !target && state.providers.length >= 100)
|
|
144
|
+
throw new WebRequestRejected("Provider limit reached.");
|
|
145
|
+
let value;
|
|
146
|
+
if (draft) {
|
|
147
|
+
if (typeof draft.name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(draft.name)
|
|
148
|
+
|| typeof draft.prefix !== "string" || !/^[a-zA-Z0-9_-]{1,64}\/$/.test(draft.prefix)
|
|
149
|
+
|| !["direct", "cpa"].includes(String(draft.via)) || typeof draft.baseUrl !== "string"
|
|
150
|
+
|| typeof draft.enabled !== "boolean"
|
|
151
|
+
|| (draft.apiKey !== undefined && (typeof draft.apiKey !== "string" || draft.apiKey.length > 8192 || /[\0\r\n]/.test(draft.apiKey)))) {
|
|
152
|
+
throw new WebRequestRejected("Expected a name, unique namespace ending in /, direct/cpa route, URL, enabled flag and optional API key.");
|
|
153
|
+
}
|
|
154
|
+
modelUrl(draft.baseUrl);
|
|
155
|
+
if (state.providers.some(p => p !== target && (p.value.name === draft.name || p.value.prefix === draft.prefix))) {
|
|
156
|
+
throw new WebRequestRejected("Provider name or model namespace already exists.");
|
|
157
|
+
}
|
|
158
|
+
const key = typeof draft.apiKey === "string" ? draft.apiKey.trim() : "";
|
|
159
|
+
if (!target && !key)
|
|
160
|
+
throw new WebRequestRejected("A new API-key connection requires a key.");
|
|
161
|
+
const previousBase = target?.value.base_url ?? target?.value.base;
|
|
162
|
+
const sameDestination = target && safeUrl(draft.baseUrl) === safeUrl(previousBase);
|
|
163
|
+
if (target && !sameDestination && !key) {
|
|
164
|
+
throw new WebRequestRejected("Changing the credential destination requires a newly supplied key.");
|
|
165
|
+
}
|
|
166
|
+
value = { ...(target?.value ?? {
|
|
167
|
+
wire_api: "responses", protocol: "responses", requestMode: "passthrough",
|
|
168
|
+
compaction: { mode: "passthrough" }, catalog: { source: "remote" }, priority: 50
|
|
169
|
+
}), name: draft.name, via: draft.via, prefix: draft.prefix, enabled: draft.enabled };
|
|
170
|
+
// metabridge's base prepends /v1; base_url is already a complete API root.
|
|
171
|
+
// Keep the existing spelling even when the address is intentionally edited.
|
|
172
|
+
value[target?.value.base !== undefined ? "base" : "base_url"] = sameDestination ? previousBase : draft.baseUrl;
|
|
173
|
+
if (key) {
|
|
174
|
+
const auth = target?.value.auth;
|
|
175
|
+
value.auth = auth && ["static", "gateway"].includes(String(auth.type))
|
|
176
|
+
? { ...auth, key } : { type: "static", key };
|
|
177
|
+
}
|
|
178
|
+
const auth = value.auth;
|
|
179
|
+
if (draft.enabled && ["static", "gateway"].includes(String(auth.type))
|
|
180
|
+
&& (typeof auth.key !== "string" || !auth.key.trim())) {
|
|
181
|
+
throw new WebRequestRejected("Enabling this provider requires an API key; supply one or keep the provider disabled.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const backends = state.providers.filter(p => p !== target).map(p => p.value);
|
|
185
|
+
if (!removing)
|
|
186
|
+
backends.push(value);
|
|
187
|
+
if (!backends.some(p => p.enabled !== false)) {
|
|
188
|
+
throw new WebRequestRejected("Keep at least one enabled backend before saving.");
|
|
189
|
+
}
|
|
190
|
+
// Existing cross-provider web search references must still resolve. Do not
|
|
191
|
+
// rewrite references or silently select another provider on the user's behalf.
|
|
192
|
+
for (const provider of backends) {
|
|
193
|
+
const search = provider.webSearch;
|
|
194
|
+
if (!search || typeof search !== "object" || Array.isArray(search))
|
|
195
|
+
continue;
|
|
196
|
+
const destination = backends.find(p => p.name === search.backend);
|
|
197
|
+
if (!destination || (provider.enabled !== false && destination.enabled === false)) {
|
|
198
|
+
throw new WebRequestRejected("This change would break a webSearch backend reference; manage the reference with its owner first.");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// External files never participate in Yui's DB transaction. Synchronous CAS
|
|
202
|
+
// detects observed edits; metabridge has no cross-writer locking protocol.
|
|
203
|
+
checkRevision(body);
|
|
204
|
+
const root = state.root;
|
|
205
|
+
let retainedFile;
|
|
206
|
+
if (root.value.providerFiles !== undefined) {
|
|
207
|
+
if (target && !removing)
|
|
208
|
+
replaceModelFile(target.file, value);
|
|
209
|
+
else {
|
|
210
|
+
const files = [...root.value.providerFiles];
|
|
211
|
+
if (target)
|
|
212
|
+
files.splice(state.providers.indexOf(target), 1);
|
|
213
|
+
else
|
|
214
|
+
files.push(newModelFile(dirname(root.path), draft.name, value));
|
|
215
|
+
// A failed root replacement can leave a private unreferenced file;
|
|
216
|
+
// never delete a file whose adoption by another writer is unknown.
|
|
217
|
+
try {
|
|
218
|
+
replaceModelFile(root, { ...root.value, providerFiles: files });
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (!target)
|
|
222
|
+
throw new Error("Provider file created but bridge reference update was not confirmed; inspect retained private files before retrying.", { cause: error });
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const backends = state.providers.map(p => p.value);
|
|
229
|
+
if (target) {
|
|
230
|
+
if (removing) {
|
|
231
|
+
// Inline auth may be the only copy. Preserve the complete provider in
|
|
232
|
+
// an owned 0600 file before unlinking, just as providerFiles removal
|
|
233
|
+
// leaves its existing file. Never delete this copy on uncertain save.
|
|
234
|
+
retainedFile = newModelFile(dirname(root.path), "retained", target.value);
|
|
235
|
+
backends.splice(state.providers.indexOf(target), 1);
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
backends[state.providers.indexOf(target)] = value;
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
backends.push(value);
|
|
242
|
+
try {
|
|
243
|
+
replaceModelFile(root, { ...root.value, backends });
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (retainedFile)
|
|
247
|
+
throw new Error(`Provider copy retained in ${retainedFile}; removal was not confirmed. Read current facts before retrying.`, { cause: error });
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const view = read();
|
|
252
|
+
if (removing)
|
|
253
|
+
view.notice = `Provider unlinked. Referenced files and credentials were retained; no service was stopped.${retainedFile ? ` Inline provider retained beside the bridge configuration in ${retainedFile}.` : ""} ${NOTICE}`;
|
|
254
|
+
return view;
|
|
255
|
+
}
|
|
256
|
+
function current(value) {
|
|
257
|
+
return { ...value, stale: value.observedAt !== null && now().getTime() - Date.parse(value.observedAt) > 60_000 };
|
|
258
|
+
}
|
|
259
|
+
function readCatalog(cursor = "0") {
|
|
260
|
+
snapshot();
|
|
261
|
+
if (!/^\d{1,5}$/.test(cursor) || Number(cursor) > 2000)
|
|
262
|
+
throw new WebRequestRejected("Invalid catalog cursor.");
|
|
263
|
+
const offset = Number(cursor);
|
|
264
|
+
return { ...current(catalog), items: catalog.items.slice(offset, offset + 100), total: catalog.items.length,
|
|
265
|
+
nextCursor: offset + 100 < catalog.items.length ? String(offset + 100) : null };
|
|
266
|
+
}
|
|
267
|
+
function readQuota(source) {
|
|
268
|
+
snapshot();
|
|
269
|
+
if (source !== "factory" && source !== "command-code")
|
|
270
|
+
throw new WebRequestRejected("Unsupported quota source.");
|
|
271
|
+
return current(quotas[source]);
|
|
272
|
+
}
|
|
273
|
+
async function refresh(input) {
|
|
274
|
+
const body = modelObject(input);
|
|
275
|
+
modelFields(body, ["revision", "kind", "source"]);
|
|
276
|
+
const state = checkRevision(body);
|
|
277
|
+
if (!state.connection)
|
|
278
|
+
throw new WebRequestRejected("Configure a metabridge connection first.");
|
|
279
|
+
const kind = body.kind;
|
|
280
|
+
const source = body.source;
|
|
281
|
+
if (kind !== "catalog" && kind !== "quota" || kind === "catalog" && source !== undefined
|
|
282
|
+
|| kind === "quota" && source !== "factory" && source !== "command-code") {
|
|
283
|
+
throw new WebRequestRejected("Unsupported observation.");
|
|
284
|
+
}
|
|
285
|
+
const identity = `${state.revision}/${kind}/${source ?? ""}`;
|
|
286
|
+
const active = pending.get(identity);
|
|
287
|
+
if (active)
|
|
288
|
+
return active;
|
|
289
|
+
if (pending.size >= 4)
|
|
290
|
+
throw new WebRequestRejected("Observation capacity reached; wait for the current request.");
|
|
291
|
+
const operation = (async () => {
|
|
292
|
+
const attemptedAt = now().toISOString();
|
|
293
|
+
let observation;
|
|
294
|
+
try {
|
|
295
|
+
const c = state.connection;
|
|
296
|
+
if (kind === "catalog") {
|
|
297
|
+
const key = modelKey(c.catalogKeyFile);
|
|
298
|
+
const raw = modelObject(await request(c.bridgeUrl, "/v1/models", key, 8_000));
|
|
299
|
+
if (!Array.isArray(raw.data) || raw.data.length > 2000)
|
|
300
|
+
throw new Error("invalid-or-oversize-catalog");
|
|
301
|
+
const items = raw.data.map(item => {
|
|
302
|
+
const row = modelObject(item);
|
|
303
|
+
if (typeof row.id !== "string" || !row.id || row.id.length > 2048)
|
|
304
|
+
throw new Error("invalid-catalog");
|
|
305
|
+
return { id: row.id, source: row.id.includes("/") ? row.id.split("/")[0] : "metabridge" };
|
|
306
|
+
});
|
|
307
|
+
// metabridge serves HTTP 200 even when some/all upstream catalogs fail.
|
|
308
|
+
// Its separate health read is bounded too; never expose raw upstream
|
|
309
|
+
// diagnostics, which can contain credentials or private addresses.
|
|
310
|
+
let healthError;
|
|
311
|
+
try {
|
|
312
|
+
healthError = catalogHealthError(await request(c.bridgeUrl, "/healthz", key, 8_000), items.length);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
healthError = "catalog-health-unavailable";
|
|
316
|
+
}
|
|
317
|
+
if (healthError && !items.length)
|
|
318
|
+
throw new Error(healthError);
|
|
319
|
+
observation = { status: healthError ? "partial" : "success", items, attemptedAt,
|
|
320
|
+
observedAt: now().toISOString(), stale: false, error: healthError };
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
if (!c.cpaUrl || !c.managementKeyFile)
|
|
324
|
+
throw new Error("quota-not-configured");
|
|
325
|
+
const suffix = source === "factory" ? "accounts" : "command-code";
|
|
326
|
+
const raw = await request(c.cpaUrl, `/v0/management/plugins/factory-quota/${suffix}`, modelKey(c.managementKeyFile), 30_000);
|
|
327
|
+
const items = normalizeQuota(source, raw);
|
|
328
|
+
observation = { status: items.some(item => item.status !== "success") ? "partial" : "success",
|
|
329
|
+
items, attemptedAt, observedAt: now().toISOString(), stale: false, error: null };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
const code = error instanceof Error && /^[a-z-]{1,64}$/.test(error.message) ? error.message : "observation-unavailable";
|
|
334
|
+
observation = { status: "failed", items: [], attemptedAt, observedAt: null, stale: false, error: code };
|
|
335
|
+
}
|
|
336
|
+
if (snapshot().revision !== state.revision)
|
|
337
|
+
throw new WebRequestRejected("Connection changed during observation; result discarded.");
|
|
338
|
+
if (kind === "catalog") {
|
|
339
|
+
catalog = observation;
|
|
340
|
+
return readCatalog();
|
|
341
|
+
}
|
|
342
|
+
quotas[source] = observation;
|
|
343
|
+
return readQuota(source);
|
|
344
|
+
})();
|
|
345
|
+
pending.set(identity, operation);
|
|
346
|
+
try {
|
|
347
|
+
return await operation;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
pending.delete(identity);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { read, saveConnection, saveProvider, readCatalog, readQuota, refresh };
|
|
354
|
+
}
|
|
355
|
+
function catalogHealthError(value, count) {
|
|
356
|
+
const health = modelObject(value);
|
|
357
|
+
const backends = Object.values(modelObject(health.backends));
|
|
358
|
+
const official = modelObject(health.officialModels);
|
|
359
|
+
if (health.service !== "metabridge" || health.ok !== true || !backends.length || backends.length > 100
|
|
360
|
+
|| typeof official.ok !== "boolean" || typeof health.models !== "number") {
|
|
361
|
+
throw new Error("invalid-catalog-health");
|
|
362
|
+
}
|
|
363
|
+
const active = backends.map(modelObject).filter(row => row.error !== "disabled");
|
|
364
|
+
if (!active.length || active.some(row => typeof row.ok !== "boolean"))
|
|
365
|
+
throw new Error("invalid-catalog-health");
|
|
366
|
+
if (health.degraded || active.some(row => !row.ok || row.error) || official.error)
|
|
367
|
+
return "catalog-upstream-failed";
|
|
368
|
+
// The two routes have no shared snapshot token. A changed count cannot
|
|
369
|
+
// establish a complete observation, even if both HTTP reads succeeded.
|
|
370
|
+
return health.models === count ? null : "catalog-health-mismatch";
|
|
371
|
+
}
|
|
372
|
+
function providerView(provider) {
|
|
373
|
+
const p = provider.value;
|
|
374
|
+
const auth = p.auth && typeof p.auth === "object" ? p.auth : {};
|
|
375
|
+
const editable = (p.protocol ?? "responses") === "responses"
|
|
376
|
+
// Declaring via on an implicit chain changes metabridge's request and
|
|
377
|
+
// compaction defaults. This editor does not migrate those policies.
|
|
378
|
+
&& (p.via === "direct" || p.via === "cpa")
|
|
379
|
+
&& ["static", "keyfile", "gateway"].includes(String(auth.type))
|
|
380
|
+
&& typeof p.prefix === "string" && /^[a-zA-Z0-9_-]{1,64}\/$/.test(p.prefix)
|
|
381
|
+
&& p.routeModels === undefined && p.http_headers === undefined;
|
|
382
|
+
return {
|
|
383
|
+
id: provider.id, name: safeText(p.name, 128) ?? "unnamed",
|
|
384
|
+
via: p.via === "direct" || p.via === "cpa" ? p.via : "unknown",
|
|
385
|
+
baseUrl: safeUrl(p.base_url ?? p.base), prefix: safeText(p.prefix, 128) ?? "",
|
|
386
|
+
enabled: p.enabled !== false, editable, reason: editable ? null : "Official, implicit-policy, shared-route or unsupported provider; manage with its existing owner.",
|
|
387
|
+
credentialPresent: typeof auth.key === "string" && !!auth.key || typeof auth.file === "string" && !!auth.file,
|
|
388
|
+
credentialKind: p.protocol === "codex-subscription" ? "subscription" : "key",
|
|
389
|
+
owner: "metabridge"
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function safeUrl(value) {
|
|
393
|
+
try {
|
|
394
|
+
return modelUrl(typeof value === "string" ? value : "").toString().replace(/\/+$/, "");
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function safeText(value, limit = 128) {
|
|
401
|
+
return typeof value === "string" && value.length <= limit && !/[\0\r\n]/.test(value) ? value : null;
|
|
402
|
+
}
|
|
403
|
+
function finite(value) {
|
|
404
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
405
|
+
}
|
|
406
|
+
function time(value) {
|
|
407
|
+
return typeof value === "string" && value.length < 64 && Number.isFinite(Date.parse(value)) ? value : null;
|
|
408
|
+
}
|
|
409
|
+
function windowView(value, label) {
|
|
410
|
+
const row = modelObject(value);
|
|
411
|
+
const remaining = finite(row.remainingFraction);
|
|
412
|
+
return { label, remainingFraction: remaining !== null && remaining <= 1 ? remaining : null,
|
|
413
|
+
used: finite(row.used), cap: finite(row.cap), resetTime: time(row.resetTime) };
|
|
414
|
+
}
|
|
415
|
+
function normalizeQuota(source, value) {
|
|
416
|
+
const raw = modelObject(value);
|
|
417
|
+
if (source === "command-code") {
|
|
418
|
+
if (!Array.isArray(raw.windows) || raw.windows.length > 32)
|
|
419
|
+
throw new Error("invalid-quota-response");
|
|
420
|
+
const credits = raw.credits === undefined ? {} : modelObject(raw.credits);
|
|
421
|
+
const balanceDetails = [];
|
|
422
|
+
for (const [field, kind] of [
|
|
423
|
+
["monthlyCredits", "monthly-credits"], ["purchasedCredits", "purchased-credits"], ["freeCredits", "free-credits"]
|
|
424
|
+
]) {
|
|
425
|
+
const value = finite(credits[field]);
|
|
426
|
+
if (value !== null)
|
|
427
|
+
balanceDetails.push({ kind, value });
|
|
428
|
+
}
|
|
429
|
+
// The extension supplies a total only when all three pools are known.
|
|
430
|
+
// Keep partial pools visible without inventing that total.
|
|
431
|
+
const balance = balanceDetails.length === 3 ? finite(raw.balance) : null;
|
|
432
|
+
return [{ id: "command-code", source, status: balance === null ? "partial" : "success",
|
|
433
|
+
windows: raw.windows.map((w, i) => windowView(w, safeText(modelObject(w).window) ?? `window-${i + 1}`)),
|
|
434
|
+
balance, balanceDetails, periodEnd: time(raw.periodEnd), error: balance === null ? "quota-total-unknown" : null }];
|
|
435
|
+
}
|
|
436
|
+
if (!Array.isArray(raw.accounts) || raw.accounts.length > 16)
|
|
437
|
+
throw new Error("invalid-quota-response");
|
|
438
|
+
return raw.accounts.map((account, i) => {
|
|
439
|
+
const row = modelObject(account);
|
|
440
|
+
const result = { id: `factory-${modelDigest(typeof row.id === "string" ? row.id : i).slice(0, 12)}`,
|
|
441
|
+
source, status: "failed", windows: [], balance: null, balanceDetails: [], periodEnd: null, error: "account-query-failed" };
|
|
442
|
+
if (row.error || !row.quota)
|
|
443
|
+
return result;
|
|
444
|
+
const quota = modelObject(row.quota);
|
|
445
|
+
if (Array.isArray(quota.summary)) {
|
|
446
|
+
if (quota.summary.length > 32)
|
|
447
|
+
return result;
|
|
448
|
+
const extra = quota.summary.find(metric => metric && typeof metric === "object"
|
|
449
|
+
&& metric.key === "extra_usage_balance" && metric.currency === "USD");
|
|
450
|
+
const value = finite(extra?.value);
|
|
451
|
+
if (value !== null)
|
|
452
|
+
result.balanceDetails.push({ kind: "extra-usage-usd", value });
|
|
453
|
+
}
|
|
454
|
+
if (!Array.isArray(quota.groups) || quota.groups.length > 16)
|
|
455
|
+
return result;
|
|
456
|
+
for (const group of quota.groups) {
|
|
457
|
+
const g = modelObject(group);
|
|
458
|
+
if (!Array.isArray(g.buckets) || g.buckets.length > 16)
|
|
459
|
+
return result;
|
|
460
|
+
for (const bucket of g.buckets) {
|
|
461
|
+
const b = modelObject(bucket);
|
|
462
|
+
result.windows.push(windowView(b, `${safeText(g.displayName) ?? "group"} / ${safeText(b.window) ?? "window"}`));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
result.status = result.windows.length ? "success" : "partial";
|
|
466
|
+
result.error = result.windows.length ? null : "quota-fields-unknown";
|
|
467
|
+
return result;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { WebRequestRejected } from "../web/webMutation.js";
|
|
3
|
+
export function modelObject(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5
|
+
throw new WebRequestRejected("Expected an object.");
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function modelFields(value, allowed) {
|
|
9
|
+
if (Object.keys(value).some(key => !allowed.includes(key)))
|
|
10
|
+
throw new WebRequestRejected("Unknown Model management field.");
|
|
11
|
+
}
|
|
12
|
+
/** Configuration validation does not resolve DNS or contact a service. */
|
|
13
|
+
export function modelUrl(value) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new WebRequestRejected("Expected an HTTP(S) service URL.");
|
|
20
|
+
}
|
|
21
|
+
const loopback = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
|
|
22
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash
|
|
23
|
+
|| (!loopback && url.protocol !== "https:") || value.length > 2048) {
|
|
24
|
+
throw new WebRequestRejected("Use loopback HTTP or HTTPS without credentials, query or fragment.");
|
|
25
|
+
}
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
export function validateModelConnection(value) {
|
|
29
|
+
const row = modelObject(value);
|
|
30
|
+
const fields = ["bridgeUrl", "bridgeConfigFile", "cpaUrl", "managementKeyFile", "catalogKeyFile"];
|
|
31
|
+
modelFields(row, fields);
|
|
32
|
+
for (const field of fields) {
|
|
33
|
+
if (typeof row[field] !== "string" || row[field].length > 4096
|
|
34
|
+
|| /[\0\r\n]/.test(row[field]))
|
|
35
|
+
throw new WebRequestRejected("Invalid Model connection field.");
|
|
36
|
+
}
|
|
37
|
+
const result = row;
|
|
38
|
+
modelUrl(result.bridgeUrl);
|
|
39
|
+
if (result.cpaUrl)
|
|
40
|
+
modelUrl(result.cpaUrl);
|
|
41
|
+
for (const field of ["bridgeConfigFile", "managementKeyFile", "catalogKeyFile"]) {
|
|
42
|
+
if (result[field] && !isAbsolute(result[field]))
|
|
43
|
+
throw new WebRequestRejected("Server file references must be absolute paths.");
|
|
44
|
+
}
|
|
45
|
+
return { ...result };
|
|
46
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { WebRequestRejected } from "../web/webMutation.js";
|
|
5
|
+
import { modelObject } from "./modelManagementConfig.js";
|
|
6
|
+
export function modelDigest(value) {
|
|
7
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
export function privateModelFile(path, secret = false) {
|
|
10
|
+
try {
|
|
11
|
+
if (!isAbsolute(path) || realpathSync(path) !== resolve(path))
|
|
12
|
+
throw new Error();
|
|
13
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
try {
|
|
15
|
+
const stat = fstatSync(fd);
|
|
16
|
+
const limit = secret ? 16384 : 2 * 1024 * 1024;
|
|
17
|
+
if (!stat.isFile() || stat.size > limit
|
|
18
|
+
|| (process.getuid && stat.uid !== process.getuid())
|
|
19
|
+
|| (secret && (stat.mode & 0o077) !== 0))
|
|
20
|
+
throw new Error();
|
|
21
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
22
|
+
let total = 0;
|
|
23
|
+
while (total < buffer.length) {
|
|
24
|
+
const count = readSync(fd, buffer, total, buffer.length - total, total);
|
|
25
|
+
if (!count)
|
|
26
|
+
break;
|
|
27
|
+
total += count;
|
|
28
|
+
}
|
|
29
|
+
if (total > limit)
|
|
30
|
+
throw new Error();
|
|
31
|
+
return buffer.subarray(0, total).toString("utf8");
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new WebRequestRejected(secret ? "Credential file unavailable; use an owned private regular file." : "Bridge configuration must be an owned, bounded, non-symlink JSON file.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function modelKey(path) {
|
|
42
|
+
if (!path)
|
|
43
|
+
return "";
|
|
44
|
+
const key = privateModelFile(path, true).trim();
|
|
45
|
+
if (!key || /[\0\r\n]/.test(key))
|
|
46
|
+
throw new WebRequestRejected("Credential file is empty or invalid.");
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
export function modelJsonFile(path) {
|
|
50
|
+
const raw = privateModelFile(path);
|
|
51
|
+
try {
|
|
52
|
+
return { path, raw, value: modelObject(JSON.parse(raw)) };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new WebRequestRejected("Bridge configuration is not a JSON object.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Atomic replacement, with optimistic conflict detection against the read bytes. */
|
|
59
|
+
export function replaceModelFile(file, value) {
|
|
60
|
+
modelDirectory(dirname(file.path));
|
|
61
|
+
if (privateModelFile(file.path) !== file.raw)
|
|
62
|
+
throw new WebRequestRejected("Bridge configuration changed; read it again.");
|
|
63
|
+
const temporary = join(dirname(file.path), `.yui-model-${randomUUID()}.tmp`);
|
|
64
|
+
let created = false;
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
67
|
+
created = true;
|
|
68
|
+
if (privateModelFile(file.path) !== file.raw)
|
|
69
|
+
throw new WebRequestRejected("Bridge configuration changed; read it again.");
|
|
70
|
+
renameSync(temporary, file.path);
|
|
71
|
+
created = false;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (created)
|
|
75
|
+
unlinkSync(temporary);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function newModelFile(directory, name, value) {
|
|
79
|
+
modelDirectory(directory);
|
|
80
|
+
const filename = `yui-provider-${name}-${randomUUID()}.json`;
|
|
81
|
+
writeFileSync(join(directory, filename), `${JSON.stringify(value, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
82
|
+
return filename;
|
|
83
|
+
}
|
|
84
|
+
function modelDirectory(directory) {
|
|
85
|
+
const stat = lstatSync(directory);
|
|
86
|
+
if (realpathSync(directory) !== resolve(directory) || !stat.isDirectory()
|
|
87
|
+
|| (stat.mode & 0o022) !== 0 || (process.getuid && stat.uid !== process.getuid())) {
|
|
88
|
+
throw new WebRequestRejected("Bridge configuration directory must be owned, non-symlink and not writable by other users.");
|
|
89
|
+
}
|
|
90
|
+
}
|