create-windy 0.6.0 → 0.6.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/dist/cli.js +82 -2
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
- package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
- package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
- package/template/apps/embedding-server/tests/test_config.py +13 -0
- package/template/apps/embedding-server/tests/test_readiness.py +202 -0
- package/template/apps/embedding-server/tests/test_run_http.py +76 -4
- package/template/apps/embedding-server/tests/test_run_service.py +3 -1
- package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
- package/template/apps/server/src/agent/run-facade.ts +2 -1
- package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
- package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
- package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
- package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
- package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
- package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
- package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
- package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
- package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
- package/template/apps/server/src/embedding/contracts.ts +6 -1
- package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
- package/template/apps/server/src/embedding/facade.ts +8 -6
- package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
- package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
- package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
- package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
- package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
- package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
- package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
- package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
- package/template/docker-compose.yml +11 -0
- package/template/docs/architecture/ai-runtime-readiness.md +6 -0
- package/template/docs/platform/vision-ai-runtime.md +22 -1
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
- package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
- package/template/packages/modules/src/manifest.ts +8 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +3 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
- package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
3
|
+
import { featureKey } from "@southwind-ai/shared";
|
|
4
|
+
import type {
|
|
5
|
+
AiRuntimeDeploymentV1,
|
|
6
|
+
ModuleAiJsonValue,
|
|
7
|
+
ModuleAiOperationReadinessV1,
|
|
8
|
+
} from "@southwind-ai/server-sdk";
|
|
9
|
+
import { buildScopeContext } from "../data-access/context.js";
|
|
10
|
+
import { createRequestGuardContext } from "../guards.js";
|
|
11
|
+
import { initializeModuleHosts } from "../module-host.js";
|
|
12
|
+
import { createServerRuntime } from "../runtime.js";
|
|
13
|
+
import type { EmbeddingRuntimeOperation } from "./contracts.js";
|
|
14
|
+
import { EmbeddingFacade } from "./facade.js";
|
|
15
|
+
|
|
16
|
+
const operationKey = "analysis.embedding.image";
|
|
17
|
+
const deployment: AiRuntimeDeploymentV1 = {
|
|
18
|
+
key: "embedding.primary",
|
|
19
|
+
identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
|
|
20
|
+
releaseDigest: `sha256:${"a".repeat(64)}`,
|
|
21
|
+
offersDigest: `sha256:${"b".repeat(64)}`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("EmbeddingFacade readiness admission", () => {
|
|
25
|
+
test("unavailable 与 probe 异常在业务 prepare、Artifact、Run 前稳定失败关闭", async () => {
|
|
26
|
+
for (const inspect of [
|
|
27
|
+
async () => unavailableProjection(),
|
|
28
|
+
async () => {
|
|
29
|
+
throw new Error("provider secret");
|
|
30
|
+
},
|
|
31
|
+
]) {
|
|
32
|
+
const fixture = await createFixture(true, inspect);
|
|
33
|
+
|
|
34
|
+
await expect(fixture.start()).rejects.toMatchObject({
|
|
35
|
+
code: "RUNTIME_UNAVAILABLE",
|
|
36
|
+
message: "Embedding Runtime 当前不可用",
|
|
37
|
+
status: 503,
|
|
38
|
+
});
|
|
39
|
+
expect(fixture.calls).toEqual({
|
|
40
|
+
readiness: 1,
|
|
41
|
+
prepare: 0,
|
|
42
|
+
artifact: 0,
|
|
43
|
+
run: 0,
|
|
44
|
+
remote: 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ready projection 固定 deployment pin 后才创建 Artifact 与 Run", async () => {
|
|
50
|
+
const fixture = await createFixture(true, async () => readyProjection());
|
|
51
|
+
|
|
52
|
+
await expect(fixture.start()).resolves.toMatchObject({
|
|
53
|
+
runId: "embedding_server_bound",
|
|
54
|
+
status: "accepted",
|
|
55
|
+
});
|
|
56
|
+
expect(fixture.calls).toEqual({
|
|
57
|
+
readiness: 1,
|
|
58
|
+
prepare: 1,
|
|
59
|
+
artifact: 1,
|
|
60
|
+
run: 1,
|
|
61
|
+
remote: 1,
|
|
62
|
+
});
|
|
63
|
+
expect(fixture.request?.readinessPin).toEqual(deployment);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("未声明 readiness 的旧 Operation 保留兼容路径", async () => {
|
|
67
|
+
const fixture = await createFixture(false, async () => {
|
|
68
|
+
throw new Error("legacy operation must not inspect V1");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await expect(fixture.start()).resolves.toMatchObject({
|
|
72
|
+
runId: "embedding_server_bound",
|
|
73
|
+
});
|
|
74
|
+
expect(fixture.calls.readiness).toBe(0);
|
|
75
|
+
expect(fixture.request?.readinessPin).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function createFixture(
|
|
80
|
+
withReadiness: boolean,
|
|
81
|
+
inspect: () => Promise<ModuleAiOperationReadinessV1>,
|
|
82
|
+
) {
|
|
83
|
+
const module = embeddingModule(withReadiness);
|
|
84
|
+
const calls = { readiness: 0, prepare: 0, artifact: 0, run: 0, remote: 0 };
|
|
85
|
+
let request: EmbeddingRuntimeOperation | undefined;
|
|
86
|
+
const hosts = await initializeModuleHosts({
|
|
87
|
+
modules: [module],
|
|
88
|
+
installations: [
|
|
89
|
+
{
|
|
90
|
+
manifest: module,
|
|
91
|
+
setup(host) {
|
|
92
|
+
host.registerAiOperationHandlers([
|
|
93
|
+
{
|
|
94
|
+
operationKey,
|
|
95
|
+
prepareEmbedding: (input: ModuleAiJsonValue) => {
|
|
96
|
+
calls.prepare += 1;
|
|
97
|
+
return { options: input };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
const facade = new EmbeddingFacade(
|
|
106
|
+
[module],
|
|
107
|
+
hosts,
|
|
108
|
+
createServerRuntime({}, { featureCatalog: module.features }),
|
|
109
|
+
{
|
|
110
|
+
async start(value: EmbeddingRuntimeOperation) {
|
|
111
|
+
calls.remote += 1;
|
|
112
|
+
request = value;
|
|
113
|
+
return { runId: "embedding_server_bound" };
|
|
114
|
+
},
|
|
115
|
+
} as never,
|
|
116
|
+
{
|
|
117
|
+
async rotate() {
|
|
118
|
+
calls.artifact += 1;
|
|
119
|
+
return artifact("rotated");
|
|
120
|
+
},
|
|
121
|
+
} as never,
|
|
122
|
+
{
|
|
123
|
+
async issue() {
|
|
124
|
+
calls.run += 1;
|
|
125
|
+
return { runId: "embedding_server_bound" };
|
|
126
|
+
},
|
|
127
|
+
async bind() {},
|
|
128
|
+
} as never,
|
|
129
|
+
{
|
|
130
|
+
async inspect() {
|
|
131
|
+
calls.readiness += 1;
|
|
132
|
+
return inspect();
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
return {
|
|
137
|
+
calls,
|
|
138
|
+
get request() {
|
|
139
|
+
return request;
|
|
140
|
+
},
|
|
141
|
+
start: () =>
|
|
142
|
+
facade.start(
|
|
143
|
+
operationKey,
|
|
144
|
+
[{ id: "image", source: artifact("submitted") }],
|
|
145
|
+
{},
|
|
146
|
+
"embedding-1",
|
|
147
|
+
guardContext(),
|
|
148
|
+
AbortSignal.timeout(1_000),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function embeddingModule(withReadiness: boolean): ModuleManifest {
|
|
154
|
+
return {
|
|
155
|
+
name: "analysis",
|
|
156
|
+
version: "0.1.0",
|
|
157
|
+
label: "分析",
|
|
158
|
+
description: "分析",
|
|
159
|
+
menus: [],
|
|
160
|
+
adminRoutes: [],
|
|
161
|
+
permissions: [
|
|
162
|
+
{
|
|
163
|
+
key: "analysis.embedding.execute",
|
|
164
|
+
label: "向量化",
|
|
165
|
+
module: "analysis",
|
|
166
|
+
moduleLabel: "分析",
|
|
167
|
+
resource: "embedding",
|
|
168
|
+
resourceLabel: "向量化",
|
|
169
|
+
action: "manage",
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
apiPermissions: [],
|
|
173
|
+
features: [
|
|
174
|
+
{
|
|
175
|
+
key: featureKey("analysis.embedding"),
|
|
176
|
+
label: "向量化",
|
|
177
|
+
module: "analysis",
|
|
178
|
+
visible: "visible",
|
|
179
|
+
enabled: true,
|
|
180
|
+
stage: "stable",
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
migrations: [],
|
|
184
|
+
schemaExports: [],
|
|
185
|
+
jsonSchemas: [
|
|
186
|
+
{ key: "analysis.embedding.input.v1", schema: { type: "object" } },
|
|
187
|
+
{ key: "analysis.embedding.output.v1", schema: { type: "object" } },
|
|
188
|
+
],
|
|
189
|
+
tasks: [],
|
|
190
|
+
tools: [],
|
|
191
|
+
aiOperations: [
|
|
192
|
+
{
|
|
193
|
+
key: operationKey,
|
|
194
|
+
kind: "embedding",
|
|
195
|
+
featureKey: "analysis.embedding",
|
|
196
|
+
permissionKey: "analysis.embedding.execute",
|
|
197
|
+
auditAction: "analysis.embedding.execute",
|
|
198
|
+
inputSchemaKey: "analysis.embedding.input.v1",
|
|
199
|
+
outputSchemaKey: "analysis.embedding.output.v1",
|
|
200
|
+
inputModalities: ["image"],
|
|
201
|
+
...(withReadiness
|
|
202
|
+
? {
|
|
203
|
+
readiness: {
|
|
204
|
+
contractVersion: 1 as const,
|
|
205
|
+
variant: "image",
|
|
206
|
+
inputModalities: ["image"] as const,
|
|
207
|
+
outputModalities: ["embedding"] as const,
|
|
208
|
+
expectedDeployment: deployment,
|
|
209
|
+
minConcurrentRuns: 1,
|
|
210
|
+
minQueueDepth: 0,
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
requiredCapabilities: ["embedding.image"],
|
|
215
|
+
allowedToolKeys: [],
|
|
216
|
+
dataClassification: "sensitive",
|
|
217
|
+
execution: "background",
|
|
218
|
+
providerEgress: { mode: "forbidden" },
|
|
219
|
+
limits: {
|
|
220
|
+
maxDurationSeconds: 60,
|
|
221
|
+
maxInputBytes: 4_096,
|
|
222
|
+
maxOutputBytes: 4_096,
|
|
223
|
+
maxUnits: 1,
|
|
224
|
+
maxArtifacts: 1,
|
|
225
|
+
maxPixelsPerArtifact: 1_000,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
providers: [],
|
|
230
|
+
auditActions: ["analysis.embedding.execute"],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function guardContext() {
|
|
235
|
+
return createRequestGuardContext(new Request("http://localhost"), {
|
|
236
|
+
resolveActor: () => ({
|
|
237
|
+
id: "actor-1",
|
|
238
|
+
type: "user",
|
|
239
|
+
name: "测试用户",
|
|
240
|
+
roleCodes: ["analyst"],
|
|
241
|
+
permissionKeys: ["analysis.embedding.execute"],
|
|
242
|
+
scopeContext: buildScopeContext("actor-1", undefined, [
|
|
243
|
+
{
|
|
244
|
+
id: "analyst",
|
|
245
|
+
code: "analyst",
|
|
246
|
+
dataScope: "self",
|
|
247
|
+
departmentIds: [],
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
}),
|
|
251
|
+
resolveLicense: () => ({ status: "valid" }),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function artifact(value: string) {
|
|
256
|
+
return {
|
|
257
|
+
value,
|
|
258
|
+
mediaType: "image/png",
|
|
259
|
+
byteSize: 128,
|
|
260
|
+
sha256: "c".repeat(64),
|
|
261
|
+
expiresAt: "2030-01-01T00:00:00.000Z",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readyProjection(): ModuleAiOperationReadinessV1 {
|
|
266
|
+
return {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
operationKey,
|
|
269
|
+
runtimeKind: "embedding",
|
|
270
|
+
observedAt: new Date().toISOString(),
|
|
271
|
+
status: "ready",
|
|
272
|
+
validUntil: new Date(Date.now() + 60_000).toISOString(),
|
|
273
|
+
deployment,
|
|
274
|
+
offers: [
|
|
275
|
+
{
|
|
276
|
+
variant: "image",
|
|
277
|
+
capabilities: ["embedding.image"],
|
|
278
|
+
inputModalities: ["image"],
|
|
279
|
+
outputModalities: ["embedding"],
|
|
280
|
+
limits: {
|
|
281
|
+
maxDurationSeconds: 60,
|
|
282
|
+
maxInputBytes: 4_096,
|
|
283
|
+
maxOutputBytes: 4_096,
|
|
284
|
+
maxUnits: 1,
|
|
285
|
+
maxArtifacts: 1,
|
|
286
|
+
maxPixelsPerArtifact: 1_000,
|
|
287
|
+
maxConcurrentRuns: 1,
|
|
288
|
+
maxQueueDepth: 0,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
unavailableReasons: [],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function unavailableProjection(): ModuleAiOperationReadinessV1 {
|
|
297
|
+
return {
|
|
298
|
+
schemaVersion: 1,
|
|
299
|
+
operationKey,
|
|
300
|
+
runtimeKind: "embedding",
|
|
301
|
+
observedAt: new Date().toISOString(),
|
|
302
|
+
status: "unavailable",
|
|
303
|
+
deployment: null,
|
|
304
|
+
offers: [],
|
|
305
|
+
unavailableReasons: [{ code: "DOWNSTREAM_PROBE_FAILED", retryable: true }],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
@@ -31,10 +31,10 @@ import {
|
|
|
31
31
|
embeddingErrorStatus,
|
|
32
32
|
type RegisteredEmbeddingOperation,
|
|
33
33
|
} from "./facade-types.js";
|
|
34
|
+
import { type ReadinessPort, requireReadiness } from "./readiness-admission.js";
|
|
34
35
|
|
|
35
36
|
export class EmbeddingFacade {
|
|
36
37
|
readonly #operations: Map<string, RegisteredEmbeddingOperation>;
|
|
37
|
-
|
|
38
38
|
constructor(
|
|
39
39
|
modules: readonly ModuleManifest[],
|
|
40
40
|
hosts: InitializedModuleHosts,
|
|
@@ -42,6 +42,7 @@ export class EmbeddingFacade {
|
|
|
42
42
|
private readonly remote: EmbeddingRuntimePort,
|
|
43
43
|
private readonly artifacts: AiArtifactRuntime,
|
|
44
44
|
private readonly runs: EmbeddingRunAuthorizations,
|
|
45
|
+
private readonly readiness: ReadinessPort,
|
|
45
46
|
) {
|
|
46
47
|
const handlers = new Map(
|
|
47
48
|
hosts.aiOperationHandlers.map(({ registration, moduleName }) => [
|
|
@@ -68,7 +69,6 @@ export class EmbeddingFacade {
|
|
|
68
69
|
),
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
72
|
async embed(
|
|
73
73
|
operationKey: string,
|
|
74
74
|
items: readonly EmbeddingFacadeItem[],
|
|
@@ -113,7 +113,6 @@ export class EmbeddingFacade {
|
|
|
113
113
|
throw error;
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
|
|
117
116
|
async start(
|
|
118
117
|
operationKey: string,
|
|
119
118
|
items: readonly EmbeddingFacadeItem[],
|
|
@@ -133,6 +132,7 @@ export class EmbeddingFacade {
|
|
|
133
132
|
operationKey,
|
|
134
133
|
prepared.items,
|
|
135
134
|
prepared.request.options,
|
|
135
|
+
prepared.request.readinessPin,
|
|
136
136
|
);
|
|
137
137
|
const binding = await this.runs.issue({
|
|
138
138
|
operationKey,
|
|
@@ -176,7 +176,6 @@ export class EmbeddingFacade {
|
|
|
176
176
|
throw error;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
-
|
|
180
179
|
async authorizeRun(runId: string, context: RequestGuardContext) {
|
|
181
180
|
const binding = await this.runs.require(runId, context);
|
|
182
181
|
const operation = this.#operations.get(binding.operationKey);
|
|
@@ -226,7 +225,6 @@ export class EmbeddingFacade {
|
|
|
226
225
|
await this.remote.cancel(runId, undefined, signal);
|
|
227
226
|
await this.auditTerminal(binding, context, "cancelled");
|
|
228
227
|
}
|
|
229
|
-
|
|
230
228
|
async openRunArtifact(runId: string, itemId: string, signal: AbortSignal) {
|
|
231
229
|
const initial = await this.runs.find(runId);
|
|
232
230
|
if (!initial) throw unavailableArtifact();
|
|
@@ -250,7 +248,6 @@ export class EmbeddingFacade {
|
|
|
250
248
|
signal,
|
|
251
249
|
);
|
|
252
250
|
}
|
|
253
|
-
|
|
254
251
|
private async prepare(
|
|
255
252
|
operationKey: string,
|
|
256
253
|
items: readonly EmbeddingFacadeItem[],
|
|
@@ -266,6 +263,10 @@ export class EmbeddingFacade {
|
|
|
266
263
|
404,
|
|
267
264
|
);
|
|
268
265
|
await authorizeEmbeddingOperation(this.runtime, operation, context);
|
|
266
|
+
const readinessPin = await requireReadiness(
|
|
267
|
+
this.readiness,
|
|
268
|
+
operation.definition,
|
|
269
|
+
);
|
|
269
270
|
const governed = governEmbeddingInput(items, options, operation.definition);
|
|
270
271
|
const plan = await operation.registration.prepareEmbedding(governed, {
|
|
271
272
|
actor: toModuleActor(context.actor),
|
|
@@ -316,6 +317,7 @@ export class EmbeddingFacade {
|
|
|
316
317
|
},
|
|
317
318
|
providerEgress: resolveProviderEgress(operation.definition),
|
|
318
319
|
providerDataPolicy: operation.definition.providerDataPolicy,
|
|
320
|
+
readinessPin,
|
|
319
321
|
},
|
|
320
322
|
};
|
|
321
323
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ModuleAiOperationDefinition } from "@southwind-ai/modules";
|
|
2
|
+
import type {
|
|
3
|
+
AiRuntimeDeploymentV1,
|
|
4
|
+
ModuleAiOperationReadinessPortV1,
|
|
5
|
+
} from "@southwind-ai/server-sdk";
|
|
6
|
+
import { EmbeddingFacadeError } from "./error.js";
|
|
7
|
+
|
|
8
|
+
export type ReadinessPort = ModuleAiOperationReadinessPortV1;
|
|
9
|
+
|
|
10
|
+
export async function requireReadiness(
|
|
11
|
+
readiness: ModuleAiOperationReadinessPortV1,
|
|
12
|
+
definition: ModuleAiOperationDefinition,
|
|
13
|
+
): Promise<AiRuntimeDeploymentV1 | undefined> {
|
|
14
|
+
if (!definition.readiness) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const projected = await readiness.inspect({ operationKey: definition.key });
|
|
17
|
+
if (projected.status !== "ready") throw new Error("not ready");
|
|
18
|
+
return projected.deployment;
|
|
19
|
+
} catch {
|
|
20
|
+
throw new EmbeddingFacadeError(
|
|
21
|
+
"RUNTIME_UNAVAILABLE",
|
|
22
|
+
"Embedding Runtime 当前不可用",
|
|
23
|
+
503,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeAiRuntimeOffersDigestV1 } from "@southwind-ai/server-sdk";
|
|
2
3
|
import {
|
|
3
4
|
RemoteEmbeddingRuntime,
|
|
4
5
|
unavailableEmbeddingRuntime,
|
|
@@ -81,4 +82,88 @@ describe("RemoteEmbeddingRuntime", () => {
|
|
|
81
82
|
runtime.start(operation, "run-key", "embedding_server_bound"),
|
|
82
83
|
).resolves.toEqual({ runId: "embedding_server_bound" });
|
|
83
84
|
});
|
|
85
|
+
|
|
86
|
+
test("deployment pin 漂移时在创建远端 Run 前失败关闭", async () => {
|
|
87
|
+
const pin = embeddingPin();
|
|
88
|
+
let starts = 0;
|
|
89
|
+
const runtime = new RemoteEmbeddingRuntime({
|
|
90
|
+
baseUrl: "http://embedding.internal",
|
|
91
|
+
internalToken: "token",
|
|
92
|
+
fetch: async (input) => {
|
|
93
|
+
const url = String(input);
|
|
94
|
+
if (url.endsWith("/health/ready")) {
|
|
95
|
+
return Response.json(
|
|
96
|
+
readinessSnapshot(pin, `sha256:${"f".repeat(64)}`),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
starts += 1;
|
|
100
|
+
return Response.json({ runId: "embedding_server_bound" });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
runtime.start(
|
|
106
|
+
{ ...operation, readinessPin: pin },
|
|
107
|
+
"run-key",
|
|
108
|
+
"embedding_server_bound",
|
|
109
|
+
),
|
|
110
|
+
).rejects.toMatchObject({ code: "RUNTIME_UNAVAILABLE" });
|
|
111
|
+
expect(starts).toBe(0);
|
|
112
|
+
});
|
|
84
113
|
});
|
|
114
|
+
|
|
115
|
+
function embeddingPin() {
|
|
116
|
+
const offersDigest = computeAiRuntimeOffersDigestV1([embeddingOffer()]);
|
|
117
|
+
return {
|
|
118
|
+
key: "embedding.primary",
|
|
119
|
+
identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
|
|
120
|
+
releaseDigest: `sha256:${"a".repeat(64)}` as const,
|
|
121
|
+
offersDigest,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readinessSnapshot(
|
|
126
|
+
pin: ReturnType<typeof embeddingPin>,
|
|
127
|
+
offersDigest = pin.offersDigest,
|
|
128
|
+
) {
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const checkedAt = now.toISOString();
|
|
131
|
+
const validUntil = new Date(now.getTime() + 60_000).toISOString();
|
|
132
|
+
const deployment = { ...pin, offersDigest };
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
status: "ready",
|
|
136
|
+
configured: true,
|
|
137
|
+
runtimeKind: "embedding",
|
|
138
|
+
observedAt: checkedAt,
|
|
139
|
+
downstreamProbe: {
|
|
140
|
+
status: "passed",
|
|
141
|
+
checkedAt,
|
|
142
|
+
validUntil,
|
|
143
|
+
latencyMs: 1,
|
|
144
|
+
deploymentIdentity: pin.identity,
|
|
145
|
+
releaseDigest: pin.releaseDigest,
|
|
146
|
+
offersDigest,
|
|
147
|
+
},
|
|
148
|
+
deployment,
|
|
149
|
+
offers: [embeddingOffer()],
|
|
150
|
+
unavailableReasons: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function embeddingOffer() {
|
|
155
|
+
return {
|
|
156
|
+
variant: "text",
|
|
157
|
+
capabilities: ["embedding.text"],
|
|
158
|
+
inputModalities: ["text"] as const,
|
|
159
|
+
outputModalities: ["embedding"] as const,
|
|
160
|
+
limits: {
|
|
161
|
+
maxDurationSeconds: 30,
|
|
162
|
+
maxInputBytes: 1024,
|
|
163
|
+
maxOutputBytes: 1024,
|
|
164
|
+
maxUnits: 10,
|
|
165
|
+
maxConcurrentRuns: 1,
|
|
166
|
+
maxQueueDepth: 0,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type EmbeddingRuntimeOperation,
|
|
5
5
|
type EmbeddingRuntimePort,
|
|
6
6
|
} from "./contracts.js";
|
|
7
|
+
import { assertAiRuntimeReadinessPin } from "../ai-runtime-readiness/deployment-pin.js";
|
|
7
8
|
|
|
8
9
|
export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
|
|
9
10
|
readonly #baseUrl: string;
|
|
@@ -32,6 +33,7 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
|
|
|
32
33
|
idempotencyKey: string,
|
|
33
34
|
signal?: AbortSignal,
|
|
34
35
|
): Promise<EmbeddingBatchResponse> {
|
|
36
|
+
await this.assertReadiness(request, signal);
|
|
35
37
|
const response = await this.request("/api/embeddings", {
|
|
36
38
|
method: "POST",
|
|
37
39
|
headers: {
|
|
@@ -55,6 +57,7 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
|
|
|
55
57
|
runId: string,
|
|
56
58
|
signal?: AbortSignal,
|
|
57
59
|
) {
|
|
60
|
+
await this.assertReadiness(request, signal);
|
|
58
61
|
const response = await this.request("/api/embeddings/runs", {
|
|
59
62
|
method: "POST",
|
|
60
63
|
headers: {
|
|
@@ -125,6 +128,24 @@ export class RemoteEmbeddingRuntime implements EmbeddingRuntimePort {
|
|
|
125
128
|
return response;
|
|
126
129
|
});
|
|
127
130
|
}
|
|
131
|
+
|
|
132
|
+
private async assertReadiness(
|
|
133
|
+
request: EmbeddingRuntimeOperation,
|
|
134
|
+
signal?: AbortSignal,
|
|
135
|
+
) {
|
|
136
|
+
if (!request.readinessPin) return;
|
|
137
|
+
try {
|
|
138
|
+
await assertAiRuntimeReadinessPin(request.readinessPin, () =>
|
|
139
|
+
this.health(signal),
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new EmbeddingRuntimeError(
|
|
143
|
+
"RUNTIME_UNAVAILABLE",
|
|
144
|
+
"Embedding Runtime readiness 已漂移",
|
|
145
|
+
true,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
128
149
|
}
|
|
129
150
|
|
|
130
151
|
export const unavailableEmbeddingRuntime: EmbeddingRuntimePort = {
|
|
@@ -2,6 +2,12 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { embeddingRunFingerprint } from "./run-fingerprint.js";
|
|
3
3
|
|
|
4
4
|
test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与 options", async () => {
|
|
5
|
+
const pin = {
|
|
6
|
+
key: "embedding.primary",
|
|
7
|
+
identity: "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f",
|
|
8
|
+
releaseDigest: `sha256:${"a".repeat(64)}` as const,
|
|
9
|
+
offersDigest: `sha256:${"b".repeat(64)}` as const,
|
|
10
|
+
};
|
|
5
11
|
const first = await embeddingRunFingerprint(
|
|
6
12
|
"analysis.embedding.image",
|
|
7
13
|
[
|
|
@@ -16,6 +22,7 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
|
|
|
16
22
|
},
|
|
17
23
|
],
|
|
18
24
|
{ nested: { limit: 2 } },
|
|
25
|
+
pin,
|
|
19
26
|
);
|
|
20
27
|
const retry = await embeddingRunFingerprint(
|
|
21
28
|
"analysis.embedding.image",
|
|
@@ -31,6 +38,7 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
|
|
|
31
38
|
},
|
|
32
39
|
],
|
|
33
40
|
{ nested: { limit: 2 } },
|
|
41
|
+
pin,
|
|
34
42
|
);
|
|
35
43
|
const changed = await embeddingRunFingerprint(
|
|
36
44
|
"analysis.embedding.image",
|
|
@@ -46,7 +54,25 @@ test("Run fingerprint 排除旋转后的 Artifact token,保留稳定输入与
|
|
|
46
54
|
},
|
|
47
55
|
],
|
|
48
56
|
{ nested: { limit: 2 } },
|
|
57
|
+
{ ...pin, offersDigest: `sha256:${"c".repeat(64)}` },
|
|
58
|
+
);
|
|
59
|
+
const deploymentChanged = await embeddingRunFingerprint(
|
|
60
|
+
"analysis.embedding.image",
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
id: "image-1",
|
|
64
|
+
source: {
|
|
65
|
+
value: "rotated-token-four",
|
|
66
|
+
mediaType: "image/png",
|
|
67
|
+
byteSize: 12,
|
|
68
|
+
sha256: "a".repeat(64),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
{ nested: { limit: 2 } },
|
|
73
|
+
{ ...pin, offersDigest: `sha256:${"c".repeat(64)}` },
|
|
49
74
|
);
|
|
50
75
|
expect(retry).toBe(first);
|
|
51
76
|
expect(changed).not.toBe(first);
|
|
77
|
+
expect(deploymentChanged).not.toBe(first);
|
|
52
78
|
});
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AiRuntimeDeploymentV1,
|
|
3
|
+
ModuleAiJsonValue,
|
|
4
|
+
} from "@southwind-ai/server-sdk";
|
|
2
5
|
import type { EmbeddingRuntimeItem } from "./contracts.js";
|
|
3
6
|
|
|
4
7
|
export async function embeddingRunFingerprint(
|
|
5
8
|
operationKey: string,
|
|
6
9
|
items: readonly EmbeddingRuntimeItem[],
|
|
7
10
|
options: ModuleAiJsonValue,
|
|
11
|
+
readinessPin?: AiRuntimeDeploymentV1,
|
|
8
12
|
) {
|
|
9
13
|
const digest = await crypto.subtle.digest(
|
|
10
14
|
"SHA-256",
|
|
11
15
|
new TextEncoder().encode(
|
|
12
|
-
stableJson({
|
|
16
|
+
stableJson({
|
|
17
|
+
operationKey,
|
|
18
|
+
items: items.map(stableItem),
|
|
19
|
+
options,
|
|
20
|
+
...(readinessPin ? { readinessPin } : {}),
|
|
21
|
+
}),
|
|
13
22
|
),
|
|
14
23
|
);
|
|
15
24
|
return [...new Uint8Array(digest)]
|