@mokuyoaxis/dsh-iris 0.2.6 → 0.3.0-alpha.1
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 +103 -1
- package/README.en.md +104 -304
- package/README.md +101 -302
- package/bin/dsh-iris.js +446 -64
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +7 -5
- package/docs/ARCHITECTURE.md +20 -4
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CLI_MANAGEMENT.md +1 -1
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +94 -0
- package/docs/DSH_COMPATIBILITY.md +14 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +40 -0
- package/docs/FUNCTION_MODEL_POOLS.md +1 -1
- package/docs/HEADLESS_CLI.md +12 -4
- package/docs/HOST_DOCTOR.md +13 -1
- package/docs/IMAGE_FLOW.md +75 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +62 -0
- package/docs/LOCAL_API_CONTRACT.md +140 -0
- package/docs/LOCAL_CREATION.md +56 -0
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +4 -2
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +28 -6
- package/docs/SECURITY.md +9 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +2 -2
- package/docs/WEB_PROTOTYPE.md +62 -0
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.2.7.md +62 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +132 -70
- package/lib/adapters.js +20 -11
- package/lib/api.js +38 -38
- package/lib/application-runtime.js +213 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +434 -55
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +21 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +31 -2
- package/lib/core-runtime.js +20 -0
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +84 -13
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +133 -0
- package/lib/doctor.js +12 -1
- package/lib/dsh-core-adapter.js +666 -572
- package/lib/dsh-host-adapter.js +8 -2
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +290 -0
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +232 -0
- package/lib/index.js +94 -68
- package/lib/local-api-projection.js +32 -0
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +1083 -0
- package/lib/local-creation.js +150 -0
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/private-storage.js +12 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +40 -7
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +211 -0
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/lib/workbench-management.js +8 -9
- package/package.json +22 -6
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +44 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +60 -0
- package/user_guide.md +19 -9
package/lib/provider-catalog.js
CHANGED
|
@@ -5,11 +5,13 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { CAPABILITIES } from './capability.js';
|
|
7
7
|
import { modelPool, functionModelPool, functionPolicy, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
|
|
8
|
-
import {
|
|
8
|
+
import { selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider, providerProtocolDescriptor } from './provider-protocol.js';
|
|
9
9
|
import { redactProviderMessage } from './provider-contract.js';
|
|
10
10
|
import { modelRateLimit } from './provider-health.js';
|
|
11
11
|
import { normalizeVisionInputLimits } from './vision-image-input.js';
|
|
12
12
|
import { modelDirectorySummary, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
|
|
13
|
+
import { checkedComfyWorkflow, comfyWorkflowDigest } from './comfyui-workflow.js';
|
|
14
|
+
import { assertPluginDependencyMatches } from './plugin-contract.js';
|
|
13
15
|
|
|
14
16
|
export class ProviderCatalogError extends Error {
|
|
15
17
|
constructor(code, message) {
|
|
@@ -51,7 +53,10 @@ export function loadProviderCatalog(configPath) {
|
|
|
51
53
|
fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
|
|
52
54
|
}
|
|
53
55
|
for (const [capability, assignment] of Object.entries(parsed.assignments || {})) functionPolicy(assignment, capability);
|
|
54
|
-
for (const provider of parsed.providers)
|
|
56
|
+
for (const provider of parsed.providers) {
|
|
57
|
+
validateModelDirectory(provider.modelDirectory);
|
|
58
|
+
for (const model of provider.models || []) if (model?.comfyWorkflow !== undefined) checkedComfyWorkflow(model.comfyWorkflow);
|
|
59
|
+
}
|
|
55
60
|
return parsed;
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -62,11 +67,11 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
|
|
|
62
67
|
});
|
|
63
68
|
|
|
64
69
|
/** 与 DSH 共用功能策略;CLI 不附加宿主资源。 */
|
|
65
|
-
function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
70
|
+
function candidatesFromCatalog(catalog, requestedModelRef, capability, registry) {
|
|
66
71
|
const label = CANDIDATE_CAP_LABEL[capability];
|
|
67
72
|
if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
|
|
68
73
|
const providers = catalog.providers.filter(isConfiguredProvider);
|
|
69
|
-
const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability]);
|
|
74
|
+
const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability], { registry });
|
|
70
75
|
const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true, includeUnavailable: true });
|
|
71
76
|
const requested = String(requestedModelRef || '').trim();
|
|
72
77
|
if (requested) {
|
|
@@ -95,30 +100,30 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
|
95
100
|
})));
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
99
|
-
return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE).map(route =>
|
|
100
|
-
Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model) })));
|
|
103
|
+
export function imageCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
104
|
+
return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE, registry).map(route =>
|
|
105
|
+
Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model, registry) })));
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
104
|
-
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO);
|
|
108
|
+
export function videoCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
109
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO, registry);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
108
|
-
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
|
|
112
|
+
export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
113
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS, registry);
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
export function musicCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
112
|
-
return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.MUSIC).map(route =>
|
|
113
|
-
Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model) })));
|
|
116
|
+
export function musicCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
117
|
+
return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.MUSIC, registry).map(route =>
|
|
118
|
+
Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model, registry) })));
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
117
|
-
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
|
|
121
|
+
export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
122
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE, registry);
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
export function visionCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
121
|
-
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION);
|
|
125
|
+
export function visionCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
|
|
126
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION, registry);
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
|
|
@@ -143,13 +148,13 @@ export function providerCatalogSnapshot(catalog) {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
export function catalogCapabilitySnapshot(catalog) {
|
|
151
|
+
export function catalogCapabilitySnapshot(catalog, registry) {
|
|
147
152
|
return {
|
|
148
153
|
schemaVersion: 1,
|
|
149
154
|
source: 'configuration',
|
|
150
155
|
capabilities: Object.values(CAPABILITIES).map((capability) => {
|
|
151
156
|
const policy = functionPolicy(catalog.assignments?.[capability], capability);
|
|
152
|
-
const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability]);
|
|
157
|
+
const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability], { registry });
|
|
153
158
|
const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
|
|
154
159
|
return { capability, mode: policy.mode, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
|
|
155
160
|
})
|
|
@@ -157,21 +162,26 @@ export function catalogCapabilitySnapshot(catalog) {
|
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
/** 非敏感摘要把 Task 绑定到提交时实际使用的媒体端点与协议。 */
|
|
160
|
-
export function providerTaskBinding(provider) {
|
|
165
|
+
export function providerTaskBinding(provider, registry) {
|
|
161
166
|
const baseUrl = providerMediaBaseUrl(provider);
|
|
162
167
|
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
163
|
-
let endpoint;
|
|
168
|
+
let endpoint, descriptor;
|
|
164
169
|
try {
|
|
165
170
|
const url = new URL(baseUrl);
|
|
166
171
|
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
descriptor = providerProtocolDescriptor(protocol, registry);
|
|
173
|
+
if (!descriptor) throw new Error('unknown protocol');
|
|
174
|
+
endpoint = descriptor.normalizeEndpoint(baseUrl);
|
|
175
|
+
if (typeof endpoint !== 'string' || !endpoint) throw new Error('invalid normalized endpoint');
|
|
169
176
|
} catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
|
|
170
|
-
|
|
177
|
+
const identity = protocol === 'comfyui' ? [2, protocol, endpoint, comfyWorkflowDigest(provider.comfyWorkflow)] : [1, protocol, endpoint];
|
|
178
|
+
// Only the new unified plugin contract opts in. Legacy protocols retain their exact historical binding.
|
|
179
|
+
if (descriptor.dependency) identity.push(descriptor.dependency);
|
|
180
|
+
return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
/** 恢复只使用 Task 已保存的 Provider/模型;忽略当前 assignment 与 failover 顺序。 */
|
|
174
|
-
export function providerForTaskFromCatalog(catalog, task) {
|
|
184
|
+
export function providerForTaskFromCatalog(catalog, task, registry) {
|
|
175
185
|
const capability = CAPABILITIES[
|
|
176
186
|
task?.capability === 'video' ? 'VIDEO'
|
|
177
187
|
: task?.capability === 'image' ? 'IMAGE'
|
|
@@ -187,10 +197,11 @@ export function providerForTaskFromCatalog(catalog, task) {
|
|
|
187
197
|
if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
|
|
188
198
|
const configured = matches[0];
|
|
189
199
|
if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
|
|
190
|
-
const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId)
|
|
191
|
-
: task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId) : configured;
|
|
200
|
+
const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId, registry)
|
|
201
|
+
: task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId, registry) : configured;
|
|
202
|
+
assertPluginDependencyMatches(task.pluginDependency, providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)?.dependency);
|
|
192
203
|
if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
|
|
193
|
-
if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task
|
|
204
|
+
if (providerTaskBinding(provider, registry) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点、协议或插件依赖已改变;请恢复原配置和插件版本,不会重新提交');
|
|
194
205
|
const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
|
|
195
206
|
if (!model) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `Task 原模型已移除或不再具备${label}能力`);
|
|
196
207
|
return provider;
|
|
@@ -6,16 +6,26 @@ import { isKnownCapability } from './capability.js';
|
|
|
6
6
|
import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
|
|
7
7
|
import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
|
|
8
8
|
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
9
|
-
import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
9
|
+
import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider, providerForImageModel, providerApiKey } from './provider-protocol.js';
|
|
10
10
|
import { invokeProviderOperation } from './provider-adapter.js';
|
|
11
11
|
import { redactProviderMessage } from './provider-contract.js';
|
|
12
12
|
import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure, recordModelAvailability, resetHealthOnCriticalChange } from './provider-health.js';
|
|
13
13
|
import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
|
|
14
14
|
import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding } from './model-directory.js';
|
|
15
15
|
import { refreshModelDirectory } from './model-directory-service.js';
|
|
16
|
+
import { projectProviderSettings, applyProviderSettings, applyFunctionPolicies } from './provider-settings.js';
|
|
17
|
+
import { checkedComfyWorkflow, compileComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
|
|
18
|
+
import { createComfyUiClient } from './comfyui-client.js';
|
|
19
|
+
import { inspectComfyWorkflow } from './comfyui-inspection.js';
|
|
16
20
|
|
|
17
21
|
const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
|
|
18
22
|
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
23
|
+
// Owner edits change configuration, while task health and directory caches can
|
|
24
|
+
// advance independently. Saves still load fresh records and use a byte CAS under
|
|
25
|
+
// the file lock, preserving the latest runtime evidence.
|
|
26
|
+
function settingsRevision(catalog) {
|
|
27
|
+
return hash(JSON.stringify({ ...catalog, providers: catalog.providers.map(({ health, modelDirectory, ...provider }) => provider) }));
|
|
28
|
+
}
|
|
19
29
|
const safe = value => redactProviderMessage(String(value || '').replace(/https?:\/\/[^\s<>"']+/gi, '[URL]'));
|
|
20
30
|
const text = value => { if (typeof value !== 'string' || !value.trim()) fail('ID 必须是非空字符串'); return value.trim(); };
|
|
21
31
|
const caps = value => {
|
|
@@ -33,7 +43,7 @@ function assignmentSnapshot(catalog) {
|
|
|
33
43
|
}));
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
export function configSnapshot(catalog) {
|
|
46
|
+
export function configSnapshot(catalog, registry) {
|
|
37
47
|
return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
|
|
38
48
|
ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
|
|
39
49
|
...(model.catalogStatus ? { catalogStatus: model.catalogStatus } : {}),
|
|
@@ -41,16 +51,18 @@ export function configSnapshot(catalog) {
|
|
|
41
51
|
...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
|
|
42
52
|
...(model.musicProtocol !== undefined ? { musicProtocol: model.musicProtocol } : {}),
|
|
43
53
|
...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
|
|
54
|
+
...(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow ? {
|
|
55
|
+
comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
|
|
44
56
|
...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
|
|
45
|
-
...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id) } : {}),
|
|
57
|
+
...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id, registry) } : {}),
|
|
46
58
|
verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: safe(value.at), category: safe(value.category) }])),
|
|
47
59
|
...(model.rateLimited ? { rateLimited: true, rateLimitedAt: modelRateLimit(provider, model.id).at,
|
|
48
60
|
...(modelRateLimit(provider, model.id).until ? { retryAt: modelRateLimit(provider, model.id).until }
|
|
49
61
|
: { reason: modelRateLimit(provider, model.id).reason }) } : {})
|
|
50
|
-
}))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog) };
|
|
62
|
+
}))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog, registry) };
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
export async function executeConfigCommand(configPath, command, input = {}, { signal, probe } = {}) {
|
|
65
|
+
export async function executeConfigCommand(configPath, command, input = {}, { signal, probe, registry } = {}) {
|
|
54
66
|
if (command === 'init') {
|
|
55
67
|
if (!path.isAbsolute(configPath)) fail('provider-config 必须是绝对路径');
|
|
56
68
|
ensurePrivateDir(path.dirname(configPath));
|
|
@@ -60,7 +72,74 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
60
72
|
const catalog = loadProviderCatalog(configPath);
|
|
61
73
|
const before = fs.readFileSync(configPath);
|
|
62
74
|
if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(catalog)) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
63
|
-
if (command === 'show') return
|
|
75
|
+
if (command === 'settings.show') return projectProviderSettings(catalog, settingsRevision(catalog), registry);
|
|
76
|
+
if (['settings.policies', 'settings.enabled', 'settings.remove'].includes(command)) {
|
|
77
|
+
const fields = command === 'settings.policies' ? ['expectedRevision', 'assignments'] : command === 'settings.enabled'
|
|
78
|
+
? ['expectedRevision', 'providerId', 'enabled'] : ['expectedRevision', 'providerId', 'confirm'];
|
|
79
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== fields.length
|
|
80
|
+
|| fields.some(key => !Object.hasOwn(input, key))) fail('管理请求字段不符合约定');
|
|
81
|
+
if (input.expectedRevision !== settingsRevision(catalog)) throw Object.assign(new Error('配置已变化,请重新读取后核对'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
82
|
+
if (command === 'settings.policies') applyFunctionPolicies(catalog, input.assignments);
|
|
83
|
+
else {
|
|
84
|
+
const matches = catalog.providers.filter(provider => provider.id === input.providerId);
|
|
85
|
+
if (matches.length !== 1) fail('供应商不存在或ID重复');
|
|
86
|
+
if (command === 'settings.enabled') {
|
|
87
|
+
if (typeof input.enabled !== 'boolean') fail('enabled 必须是布尔值');
|
|
88
|
+
matches[0].enabled = input.enabled;
|
|
89
|
+
} else {
|
|
90
|
+
if (input.confirm !== true) fail('请明确确认删除此供应商');
|
|
91
|
+
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
92
|
+
for (const [cap, assignment] of Object.entries(catalog.assignments || {})) {
|
|
93
|
+
const policy = functionPolicy(assignment, cap);
|
|
94
|
+
catalog.assignments[cap] = { ...policy, models: savedAssignmentRefs(pool, cap, policy.models)
|
|
95
|
+
.filter(ref => parseModelRef(ref)?.providerId !== input.providerId) };
|
|
96
|
+
}
|
|
97
|
+
catalog.providers = catalog.providers.filter(provider => provider !== matches[0]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
signal?.throwIfAborted(); writeConfigChange(configPath, catalog, before, {});
|
|
101
|
+
return projectProviderSettings(catalog, settingsRevision(catalog), registry);
|
|
102
|
+
}
|
|
103
|
+
if (command === 'models.workflow-check' || command === 'settings.workflow-check') {
|
|
104
|
+
const settings = command === 'settings.workflow-check';
|
|
105
|
+
const allowed = settings ? ['expectedRevision', 'modelRef', 'workflowParameters', 'providerId', 'comfyWorkflow'] : ['model_ref', 'workflow_parameters'];
|
|
106
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) fail('工作流检查包含不支持的字段');
|
|
107
|
+
if (settings && input.expectedRevision !== settingsRevision(catalog)) throw Object.assign(new Error('配置已变化,请重新读取后检查'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
108
|
+
const draft = settings && input.comfyWorkflow !== undefined;
|
|
109
|
+
const ref = draft ? null : parseModelRef(settings ? input.modelRef : input.model_ref);
|
|
110
|
+
if (draft ? typeof input.providerId !== 'string' || input.modelRef !== undefined : !ref || input.providerId !== undefined) fail('需要明确的模型,或已保存连接与工作流草稿');
|
|
111
|
+
const matches = catalog.providers.filter(provider => provider.id === (draft ? input.providerId : ref.providerId));
|
|
112
|
+
if (matches.length !== 1 || !isConfiguredProvider(matches[0]) || !draft && !matches[0].models?.some(model => model.id === ref.modelId)) fail('请先配置并启用明确的工作流连接');
|
|
113
|
+
const provider = draft ? { ...matches[0], comfyWorkflow: checkedComfyWorkflow(input.comfyWorkflow) } : providerForImageModel(matches[0], ref.modelId, registry);
|
|
114
|
+
if (provider.mediaProtocol !== 'comfyui') fail('此检查仅用于 ComfyUI 工作流');
|
|
115
|
+
const workflow = compileComfyWorkflow(provider.comfyWorkflow, { workflow_parameters: settings ? input.workflowParameters : input.workflow_parameters });
|
|
116
|
+
const report = await inspectComfyWorkflow(createComfyUiClient(provider), workflow, { signal });
|
|
117
|
+
signal?.throwIfAborted();
|
|
118
|
+
if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('检查期间配置已变化,请重新检查'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
119
|
+
const key = providerApiKey(provider);
|
|
120
|
+
return { ...JSON.parse(key ? JSON.stringify(report).split(key).join('[redacted]') : JSON.stringify(report)), revision: settingsRevision(catalog) };
|
|
121
|
+
}
|
|
122
|
+
if (command === 'settings.save' || command === 'settings.discover') {
|
|
123
|
+
if (!input || typeof input.expectedRevision !== 'string' || input.expectedRevision !== settingsRevision(catalog)) {
|
|
124
|
+
throw Object.assign(new Error('配置已变化,请重新读取后核对草稿'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
125
|
+
}
|
|
126
|
+
if (command === 'settings.discover') {
|
|
127
|
+
if (Object.keys(input).some(key => !['expectedRevision', 'providerId'].includes(key))) fail('目录请求包含不支持的字段');
|
|
128
|
+
const found = catalog.providers.filter(item => item.id === input.providerId);
|
|
129
|
+
if (found.length !== 1 || !isConfiguredProvider(found[0])) fail('请先保存并启用有认证配置的连接');
|
|
130
|
+
const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(found[0], { registry }), 'discover', { signal, timeoutMs: 10000 });
|
|
131
|
+
signal?.throwIfAborted();
|
|
132
|
+
if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('目录读取期间配置已变化'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
133
|
+
return { contractVersion: 0, revision: settingsRevision(catalog), models: discovered.models.slice(0, 256).map(model => ({
|
|
134
|
+
id: safe(text(typeof model === 'string' ? model : model.id)).split(found[0].apiKey || '\u0000').join('[redacted]'), capabilities: capabilitiesOfDiscoveredModel(model)
|
|
135
|
+
})), truncated: discovered.models.length > 256, persisted: false };
|
|
136
|
+
}
|
|
137
|
+
applyProviderSettings(catalog, input, registry);
|
|
138
|
+
signal?.throwIfAborted();
|
|
139
|
+
writeConfigChange(configPath, catalog, before, {});
|
|
140
|
+
return projectProviderSettings(catalog, settingsRevision(catalog), registry);
|
|
141
|
+
}
|
|
142
|
+
if (command === 'show') return configSnapshot(catalog, registry);
|
|
64
143
|
if (command === 'check') {
|
|
65
144
|
const issues = [];
|
|
66
145
|
const ids = catalog.providers.map(provider => provider.id);
|
|
@@ -70,11 +149,11 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
70
149
|
try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
|
|
71
150
|
for (const model of provider.models || []) {
|
|
72
151
|
if (model?.musicProtocol !== undefined) {
|
|
73
|
-
try { normalizeMusicProtocol(model.musicProtocol); } catch (_) { issues.push('invalid_model_music_protocol'); }
|
|
152
|
+
try { normalizeMusicProtocol(model.musicProtocol, registry); } catch (_) { issues.push('invalid_model_music_protocol'); }
|
|
74
153
|
}
|
|
75
154
|
try { normalizeVisionInputLimits(model?.visionInput); } catch (_) { issues.push('invalid_model_vision_input'); }
|
|
76
155
|
if (model?.imageProtocol === undefined) continue;
|
|
77
|
-
try { normalizeImageProtocol(model.imageProtocol); } catch (_) { issues.push('invalid_model_image_protocol'); }
|
|
156
|
+
try { normalizeImageProtocol(model.imageProtocol, registry); } catch (_) { issues.push('invalid_model_image_protocol'); }
|
|
78
157
|
}
|
|
79
158
|
}
|
|
80
159
|
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
@@ -84,8 +163,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
84
163
|
}
|
|
85
164
|
return { valid: issues.length === 0, issues: [...new Set(issues)] };
|
|
86
165
|
}
|
|
87
|
-
if (command === 'models.list') return { models: configSnapshot(catalog).models };
|
|
88
|
-
if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog), assignments: assignmentSnapshot(catalog) };
|
|
166
|
+
if (command === 'models.list') return { models: configSnapshot(catalog, registry).models };
|
|
167
|
+
if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog, registry), assignments: assignmentSnapshot(catalog) };
|
|
89
168
|
let result = { command, changed: true };
|
|
90
169
|
const initialPool = modelPool(catalog.providers);
|
|
91
170
|
let provider;
|
|
@@ -113,13 +192,16 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
113
192
|
if (model?.capabilities !== undefined) caps(model.capabilities);
|
|
114
193
|
}
|
|
115
194
|
updated.models = updated.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
116
|
-
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
117
|
-
...(model?.
|
|
195
|
+
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol, registry) } : {}),
|
|
196
|
+
...(model?.comfyWorkflow !== undefined ? { comfyWorkflow: checkedComfyWorkflow(model.comfyWorkflow) } : {}),
|
|
197
|
+
...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol, registry) } : {}),
|
|
118
198
|
...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
|
|
119
199
|
}
|
|
120
200
|
if (provider) {
|
|
121
201
|
for (const model of providerModels(provider)) {
|
|
122
|
-
if (selectImageProtocol(provider, model.id).mediaProtocol !== selectImageProtocol(updated, model.id).mediaProtocol
|
|
202
|
+
if (selectImageProtocol(provider, model.id, registry).mediaProtocol !== selectImageProtocol(updated, model.id, registry).mediaProtocol
|
|
203
|
+
|| JSON.stringify(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow)
|
|
204
|
+
!== JSON.stringify(updated.models?.find(entry => entry?.id === model.id)?.comfyWorkflow)) {
|
|
123
205
|
clearModelCapabilityHealth(updated, model.id, 'image-gen');
|
|
124
206
|
}
|
|
125
207
|
}
|
|
@@ -138,7 +220,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
138
220
|
: providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
|
|
139
221
|
let entry = entries.find(model => model.id === modelId);
|
|
140
222
|
if (command === 'models.discover') {
|
|
141
|
-
const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider), 'discover', { signal, timeoutMs: 15000 });
|
|
223
|
+
const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider, { registry }), 'discover', { signal, timeoutMs: 15000 });
|
|
142
224
|
const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
|
|
143
225
|
result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
|
|
144
226
|
if (input.apply !== true) return result;
|
|
@@ -155,8 +237,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
155
237
|
const capabilities = input.capabilities === undefined ? undefined : caps(input.capabilities);
|
|
156
238
|
if (entry) fail('模型已存在;请用 models caps 修改能力');
|
|
157
239
|
entries.push({ id: modelId, capabilities, source: 'manual',
|
|
158
|
-
...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
|
|
159
|
-
...(input.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(input.musicProtocol) } : {}),
|
|
240
|
+
...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol, registry) } : {}),
|
|
241
|
+
...(input.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(input.musicProtocol, registry) } : {}),
|
|
160
242
|
...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
|
|
161
243
|
} else if (command === 'models.remove') {
|
|
162
244
|
if (!entry) fail('模型不存在');
|
|
@@ -166,16 +248,16 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
166
248
|
entry.capabilities = caps(input.capabilities); entry.source = 'manual';
|
|
167
249
|
} else if (command === 'models.protocol') {
|
|
168
250
|
if (!entry) fail('模型不存在');
|
|
169
|
-
const protocol = normalizeImageProtocol(input.imageProtocol);
|
|
170
|
-
const previous = selectImageProtocol(provider, modelId).mediaProtocol;
|
|
251
|
+
const protocol = normalizeImageProtocol(input.imageProtocol, registry);
|
|
252
|
+
const previous = selectImageProtocol(provider, modelId, registry).mediaProtocol;
|
|
171
253
|
if (protocol === 'auto') delete entry.imageProtocol;
|
|
172
254
|
else entry.imageProtocol = protocol;
|
|
173
255
|
provider.models = entries;
|
|
174
|
-
if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
|
|
175
|
-
result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
|
|
256
|
+
if (previous !== selectImageProtocol(provider, modelId, registry).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
|
|
257
|
+
result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId, registry) };
|
|
176
258
|
} else if (command === 'models.music-protocol') {
|
|
177
259
|
if (!entry) fail('模型不存在');
|
|
178
|
-
const protocol = normalizeMusicProtocol(input.musicProtocol);
|
|
260
|
+
const protocol = normalizeMusicProtocol(input.musicProtocol, registry);
|
|
179
261
|
if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, 'music-gen');
|
|
180
262
|
entry.musicProtocol = protocol;
|
|
181
263
|
result = { ...result, model_ref: safe(input.model_ref), musicProtocol: protocol };
|
|
@@ -281,11 +363,11 @@ export function recordCatalogAvailability(configPath, { providerId, modelId, cap
|
|
|
281
363
|
}
|
|
282
364
|
|
|
283
365
|
/** 新的自动请求按需读取目录;只读CLI命令与手选/停用不走这里。 */
|
|
284
|
-
export async function refreshCatalogDirectories(configPath, catalog, capability, { signal } = {}) {
|
|
366
|
+
export async function refreshCatalogDirectories(configPath, catalog, capability, { signal, registry } = {}) {
|
|
285
367
|
if (functionPolicy(catalog.assignments?.[capability], capability).mode !== 'auto') return [];
|
|
286
368
|
return Promise.all(catalog.providers.filter(provider => isConfiguredProvider(provider)
|
|
287
369
|
&& providerModels(provider).some(model => model.capabilities.includes(capability))).map(provider =>
|
|
288
|
-
refreshModelDirectory(provider, { signal, save: directory => {
|
|
370
|
+
refreshModelDirectory(provider, { signal, registry, save: directory => {
|
|
289
371
|
const fresh = loadProviderCatalog(configPath);
|
|
290
372
|
const before = fs.readFileSync(configPath);
|
|
291
373
|
const current = fresh.providers.find(item => item.id === provider.id);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { catalogCapabilitySnapshot } from './provider-catalog.js';
|
|
2
|
+
import { providerModels, functionPolicy, parseModelRef } from './models.js';
|
|
3
|
+
import { isConfiguredProvider, supportsModelCapability } from './provider-protocol.js';
|
|
4
|
+
import { healthObservationState, newestHealthEvidence, modelRateLimit } from './provider-health.js';
|
|
5
|
+
import { listCoreTasks, findFlowTask } from './core-tasks.js';
|
|
6
|
+
import { flowReferences } from './flow-store.js';
|
|
7
|
+
|
|
8
|
+
/** Uses the same routing and health rules as submissions. No discovery, network calls or writes. */
|
|
9
|
+
export function projectProviderDiagnostics(catalog, registry) {
|
|
10
|
+
const routes = catalogCapabilitySnapshot(catalog, registry).capabilities;
|
|
11
|
+
const capabilities = routes.map(route => {
|
|
12
|
+
const models = catalog.providers.flatMap(provider => providerModels(provider).filter(model => model.capabilities.includes(route.capability)).map(model => {
|
|
13
|
+
const observation = provider.health?.observations?.find(item => item.modelId === model.id && item.capability === route.capability);
|
|
14
|
+
const legacy = model.verified?.[route.capability];
|
|
15
|
+
const evidence = observation || (legacy ? { [legacy.ok ? 'lastSuccess' : ['auth', 'authentication', 'permission'].includes(legacy.category) ? 'lastFailure' : 'lastTransient']: legacy } : undefined);
|
|
16
|
+
const limit = modelRateLimit(provider, model.id);
|
|
17
|
+
const latest = newestHealthEvidence(evidence);
|
|
18
|
+
const reason = provider.enabled === false ? 'provider_disabled' : !isConfiguredProvider(provider) ? 'connection_unconfigured'
|
|
19
|
+
: route.mode === 'disabled' ? 'function_disabled' : model.source === 'discovered' ? 'unconfirmed'
|
|
20
|
+
: !supportsModelCapability(provider, model.id, route.capability, { registry }) ? 'protocol_unsupported'
|
|
21
|
+
: model.unavailableCapabilities?.includes(route.capability) ? 'model_missing' : limit ? limit.until ? 'cooling' : 'quota'
|
|
22
|
+
: !route.candidates.includes(model.ref) ? 'outside_pool' : null;
|
|
23
|
+
return { ref: model.ref, providerId: provider.id, providerName: provider.name || provider.id, modelId: model.id,
|
|
24
|
+
eligible: !reason, reason, rank: reason ? null : route.candidates.indexOf(model.ref) + 1,
|
|
25
|
+
state: healthObservationState(evidence), evidence: latest ? { at: latest.at, source: ['probe', 'task'].includes(latest.source) ? latest.source : 'unknown',
|
|
26
|
+
category: ['success', 'authentication', 'auth', 'permission', 'rate_limit', 'quota', 'network', 'timeout', 'protocol', 'unknown'].includes(latest.category) ? latest.category : 'unknown' } : null,
|
|
27
|
+
...(limit?.until ? { retryAt: limit.until } : {}),
|
|
28
|
+
comfy: Boolean(provider.models?.find(item => item?.id === model.id)?.comfyWorkflow) };
|
|
29
|
+
}));
|
|
30
|
+
return { capability: route.capability, mode: route.mode, candidates: models.filter(model => model.eligible).sort((a, b) => a.rank - b.rank).map(model => model.ref),
|
|
31
|
+
verified: models.filter(model => model.eligible && model.state === 'verified').length, models };
|
|
32
|
+
});
|
|
33
|
+
const result = { contractVersion: 0, capabilities };
|
|
34
|
+
const keys = catalog.providers.map(provider => provider.apiKey).filter(key => typeof key === 'string' && key);
|
|
35
|
+
const scrub = value => typeof value === 'string' ? keys.reduce((text, key) => text.split(key).join('[redacted]'), value)
|
|
36
|
+
: Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)])) : value;
|
|
37
|
+
return scrub(result);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Current root only, under the caller's Core lease; unknown/corrupt records must fail visibly. */
|
|
41
|
+
export function providerDependencies(root, providerId) {
|
|
42
|
+
const tasks = []; let offset = 0, page;
|
|
43
|
+
do { page = listCoreTasks(root, { provider_id: providerId, offset, limit: 200 }); tasks.push(...page.tasks); offset += page.limit; } while (offset < page.total);
|
|
44
|
+
const recoverable = task => task.acceptance === 'unknown' || ['none', 'unknown'].includes(task.outcome)
|
|
45
|
+
|| task.outcome === 'succeeded' && task.deliveryState !== 'ready' || task.cancelState === 'requested';
|
|
46
|
+
const blockingTasks = tasks.filter(recoverable).map(task => task.id);
|
|
47
|
+
const blockingRuns = flowReferences(root).filter(({ run }) => parseModelRef(run.input.model_ref)?.providerId === providerId)
|
|
48
|
+
.filter(({ run }) => { const task = findFlowTask(root, run); return !task || recoverable(task); }).map(({ run }) => run.id);
|
|
49
|
+
return { scope: 'current-core-root', blockingTasks, blockingRuns, historicalTasks: tasks.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function providerRemovalImpact(catalog, providerId) {
|
|
53
|
+
const provider = catalog.providers.find(item => item.id === providerId);
|
|
54
|
+
return { modelCount: provider ? providerModels(provider).length : 0, functions: Object.entries(catalog.assignments || {}).filter(([cap, value]) =>
|
|
55
|
+
functionPolicy(value, cap).models.some(ref => parseModelRef(ref)?.providerId === providerId)).map(([cap]) => cap) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Experimental v0 package boundary. This is not the complete Core SDK.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { builtinProviderExtension } from './provider-adapters.js';
|
|
6
|
+
import { createExtensionRegistry } from './provider-registry.js';
|
|
7
|
+
|
|
8
|
+
export { defineProviderAdapter, PROVIDER_ADAPTER_CONTRACT_VERSION } from './provider-adapter.js';
|
|
9
|
+
export { ProviderContractError } from './provider-contract.js';
|
|
10
|
+
export { normalizeMediaEndpoint } from './provider-protocol.js';
|
|
11
|
+
export { PROVIDER_EXTENSION_CONTRACT_VERSION } from './provider-registry.js';
|
|
12
|
+
|
|
13
|
+
export function createProviderRegistry({ extensions = [] } = {}) {
|
|
14
|
+
if (!Array.isArray(extensions)) throw new TypeError('extensions 必须是工厂数组');
|
|
15
|
+
return createExtensionRegistry([builtinProviderExtension, ...extensions]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Trusted code only. Explicit absolute entry files; no discovery or package installation. */
|
|
19
|
+
export async function loadProviderRegistry(entries = []) {
|
|
20
|
+
const factories = [];
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (typeof entry !== 'string' || !path.isAbsolute(entry) || !/\.m?js$/.test(entry)
|
|
23
|
+
|| !fs.statSync(entry).isFile()) throw new TypeError('Provider 扩展入口必须是明确的 JS/MJS 文件绝对路径');
|
|
24
|
+
const module = await import(pathToFileURL(fs.realpathSync(entry)).href);
|
|
25
|
+
if (typeof module.default !== 'function') throw new TypeError('Provider 扩展须默认导出实例工厂函数');
|
|
26
|
+
factories.push(module.default);
|
|
27
|
+
}
|
|
28
|
+
return createProviderRegistry({ extensions: factories });
|
|
29
|
+
}
|
package/lib/provider-health.js
CHANGED
|
@@ -21,8 +21,15 @@ export function recordModelAvailability(provider, modelId, capability, result) {
|
|
|
21
21
|
if (!modelId || !isKnownCapability(cap)) return false;
|
|
22
22
|
let observation = provider.health?.observations?.find(item => item.modelId === modelId && item.capability === cap);
|
|
23
23
|
const blocked = isModelNotFound(result);
|
|
24
|
+
// A real accepted/completed submission is evidence for this exact connection; directory reads never enter here.
|
|
25
|
+
if (result.ok === true && result.stage === 'submit') {
|
|
26
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
27
|
+
provider.health.observations ||= [];
|
|
28
|
+
if (!observation) { observation = { modelId, capability: cap }; provider.health.observations.push(observation); }
|
|
29
|
+
observation.lastSuccess = { at: result.at || new Date().toISOString(), source: result.source === 'probe' ? 'probe' : 'task', category: 'success' };
|
|
30
|
+
}
|
|
24
31
|
if (!blocked && !(result.ok === true && (result.source === 'probe' || result.stage === 'submit')
|
|
25
|
-
&& (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return
|
|
32
|
+
&& (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return result.ok === true && result.stage === 'submit';
|
|
26
33
|
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
27
34
|
provider.health.observations ||= [];
|
|
28
35
|
observation ||= provider.health.observations.find(item => item.modelId === modelId && item.capability === cap);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Optional, service-owned, read-only connection checks. Never a media probe or durable health assertion.
|
|
2
|
+
import { loadProviderCatalog } from './provider-catalog.js';
|
|
3
|
+
import { executeConfigCommand } from './provider-config-service.js';
|
|
4
|
+
import { modelDirectoryBinding } from './model-directory.js';
|
|
5
|
+
import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
6
|
+
|
|
7
|
+
const AUTOMATIC = new Set(['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music', 'comfyui']);
|
|
8
|
+
const errorCode = error => ['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY', 'IRIS_PROVIDER_OPERATION_UNSUPPORTED'].includes(error.code) ? error.code
|
|
9
|
+
: ['authentication', 'permission', 'network', 'timeout', 'protocol', 'rate_limit', 'provider', 'aborted'].includes(error.category) ? error.category : 'connection_failed';
|
|
10
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
11
|
+
export function createProviderMonitor({ configPath, registry, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
12
|
+
const records = new Map(), pending = new Set(); let closed = false;
|
|
13
|
+
const catalog = () => { if (closed) fail('IRIS_PROVIDER_MONITOR_CLOSED', '连接检查已关闭'); return loadProviderCatalog(configPath); };
|
|
14
|
+
function record(providerId, provider = catalog().providers.find(item => item.id === providerId)) {
|
|
15
|
+
if (!provider) fail('IRIS_CONFIG_INPUT_INVALID', '供应商不存在');
|
|
16
|
+
let item = records.get(providerId);
|
|
17
|
+
if (item && item.binding !== modelDirectoryBinding(provider)) { stop(item); records.delete(providerId); item = null; }
|
|
18
|
+
if (!item) { item = { providerId, binding: modelDirectoryBinding(provider), enabled: false, intervalSeconds: 300, last: null, epoch: 0 }; records.set(providerId, item); }
|
|
19
|
+
if (!isConfiguredProvider(provider)) stop(item);
|
|
20
|
+
return { provider, item };
|
|
21
|
+
}
|
|
22
|
+
function stop(item) { item.epoch++; clearTimer(item.timer); item.enabled = false; item.controller?.abort(); }
|
|
23
|
+
function snapshot() {
|
|
24
|
+
const providers = catalog().providers.map(provider => {
|
|
25
|
+
const { item } = record(provider.id, provider);
|
|
26
|
+
return { providerId: provider.id, automaticSupported: AUTOMATIC.has(selectMediaProtocol(provider).mediaProtocol),
|
|
27
|
+
enabled: item.enabled, intervalSeconds: item.intervalSeconds, checking: Boolean(item.pending), last: item.last };
|
|
28
|
+
});
|
|
29
|
+
for (const [id, item] of records) if (!providers.some(provider => provider.providerId === id)) { stop(item); records.delete(id); }
|
|
30
|
+
return providers;
|
|
31
|
+
}
|
|
32
|
+
async function check(providerId, expectedRevision, { signal } = {}) {
|
|
33
|
+
const { provider, item } = record(providerId);
|
|
34
|
+
if (!isConfiguredProvider(provider)) fail('IRIS_CONFIG_INPUT_INVALID', '请先保存并启用供应商连接');
|
|
35
|
+
if (item.pending) fail('IRIS_API_BUSY', '此供应商正在检查,请等待结果');
|
|
36
|
+
const controller = item.controller = new AbortController();
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
const job = (async () => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await executeConfigCommand(configPath, 'settings.discover', { expectedRevision, providerId }, { registry,
|
|
41
|
+
signal: AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]) });
|
|
42
|
+
item.last = { ok: true, at: new Date().toISOString(), elapsedMs: Date.now() - started, modelCount: result.models.length };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === 'IRIS_CONFIG_CHANGED') throw error;
|
|
45
|
+
item.last = { ok: false, at: new Date().toISOString(), elapsedMs: Date.now() - started,
|
|
46
|
+
code: controller.signal.aborted || signal?.aborted ? 'check_unconfirmed' : errorCode(error),
|
|
47
|
+
...(Number.isInteger(error.httpStatus) ? { httpStatus: error.httpStatus } : {}) };
|
|
48
|
+
}
|
|
49
|
+
return { contractVersion: 0, providerId, ...item.last };
|
|
50
|
+
})();
|
|
51
|
+
item.pending = job;
|
|
52
|
+
pending.add(job);
|
|
53
|
+
try { return await job; } finally { pending.delete(job); item.pending = null; item.controller = null; }
|
|
54
|
+
}
|
|
55
|
+
function schedule(item) {
|
|
56
|
+
const epoch = item.epoch;
|
|
57
|
+
item.timer = setTimer(async () => {
|
|
58
|
+
if (!item.enabled || closed || epoch !== item.epoch) return;
|
|
59
|
+
try {
|
|
60
|
+
const view = await executeConfigCommand(configPath, 'settings.show', {}, { registry });
|
|
61
|
+
const { provider, item: current } = record(item.providerId);
|
|
62
|
+
if (current !== item || !item.enabled || !isConfiguredProvider(provider)) return;
|
|
63
|
+
if (!item.pending) await check(item.providerId, view.revision);
|
|
64
|
+
} catch (error) { item.last = { ok: false, at: new Date().toISOString(), code: errorCode(error) }; }
|
|
65
|
+
finally { if (item.enabled && !closed && epoch === item.epoch) schedule(item); }
|
|
66
|
+
}, item.intervalSeconds * 1000);
|
|
67
|
+
item.timer?.unref?.();
|
|
68
|
+
}
|
|
69
|
+
function configure({ providerId, enabled, intervalSeconds }) {
|
|
70
|
+
if (typeof enabled !== 'boolean' || ![60, 300, 900].includes(intervalSeconds)) fail('IRIS_CONFIG_INPUT_INVALID', '保活需选择开关和60/300/900秒间隔');
|
|
71
|
+
const { provider, item } = record(providerId);
|
|
72
|
+
if (enabled && (!isConfiguredProvider(provider) || !AUTOMATIC.has(selectMediaProtocol(provider).mediaProtocol))) fail('IRIS_CONFIG_INPUT_INVALID', '此连接没有已支持的自动轻量检查');
|
|
73
|
+
stop(item); item.intervalSeconds = intervalSeconds; item.enabled = enabled;
|
|
74
|
+
if (enabled) schedule(item);
|
|
75
|
+
return { contractVersion: 0, monitors: snapshot(), persistence: 'service-session' };
|
|
76
|
+
}
|
|
77
|
+
async function dispose() { closed = true; for (const item of records.values()) stop(item); await Promise.allSettled([...pending]); records.clear(); }
|
|
78
|
+
return Object.freeze({ snapshot, check, configure, dispose });
|
|
79
|
+
}
|