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.
Files changed (57) hide show
  1. package/dist/cli.js +82 -2
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
  5. package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
  6. package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
  7. package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
  8. package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
  9. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
  10. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
  11. package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
  12. package/template/apps/embedding-server/tests/test_config.py +13 -0
  13. package/template/apps/embedding-server/tests/test_readiness.py +202 -0
  14. package/template/apps/embedding-server/tests/test_run_http.py +76 -4
  15. package/template/apps/embedding-server/tests/test_run_service.py +3 -1
  16. package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
  17. package/template/apps/server/src/agent/run-facade.ts +2 -1
  18. package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
  19. package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
  20. package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
  21. package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
  22. package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
  23. package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
  24. package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
  25. package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
  26. package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
  27. package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
  28. package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
  29. package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
  30. package/template/apps/server/src/embedding/contracts.ts +6 -1
  31. package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
  32. package/template/apps/server/src/embedding/facade.ts +8 -6
  33. package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
  34. package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
  35. package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
  36. package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
  37. package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
  38. package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
  39. package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
  40. package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
  41. package/template/docker-compose.yml +11 -0
  42. package/template/docs/architecture/ai-runtime-readiness.md +6 -0
  43. package/template/docs/platform/vision-ai-runtime.md +22 -1
  44. package/template/packages/config/package.json +1 -1
  45. package/template/packages/database/package.json +1 -1
  46. package/template/packages/jobs/package.json +1 -1
  47. package/template/packages/modules/index.ts +1 -0
  48. package/template/packages/modules/package.json +1 -1
  49. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  50. package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
  51. package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
  52. package/template/packages/modules/src/manifest.ts +8 -0
  53. package/template/packages/server-sdk/package.json +1 -1
  54. package/template/packages/shared/index.ts +3 -0
  55. package/template/packages/shared/package.json +1 -1
  56. package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
  57. package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
package/dist/cli.js CHANGED
@@ -378,7 +378,9 @@ var optionalModuleManifests = [
378
378
  "packages/modules/src/system-agent-tools.ts",
379
379
  "packages/database/src/agent-runtime-schema.test.ts",
380
380
  "packages/database/src/ai-output-artifacts.test.ts",
381
- "packages/shared/src/ai-runtime-report.ts"
381
+ "packages/shared/src/ai-runtime-report.ts",
382
+ "packages/shared/src/screenshot-context-digest.ts",
383
+ "packages/shared/src/screenshot-context-digest.test.ts"
382
384
  ],
383
385
  documentation: [
384
386
  "docs/architecture/ai-runtime.md",
@@ -3092,6 +3094,9 @@ var agentComposeService = ` tool-sandbox-init:
3092
3094
  EMBEDDING_ADAPTER_URL: \${EMBEDDING_ADAPTER_URL:-}
3093
3095
  EMBEDDING_ADAPTER_TOKEN: \${EMBEDDING_ADAPTER_TOKEN:-}
3094
3096
  EMBEDDING_DEPLOYMENT_KEY: \${EMBEDDING_DEPLOYMENT_KEY:-}
3097
+ EMBEDDING_DEPLOYMENT_IDENTITY: \${EMBEDDING_DEPLOYMENT_IDENTITY:-}
3098
+ EMBEDDING_RELEASE_DIGEST: \${EMBEDDING_RELEASE_DIGEST:-}
3099
+ EMBEDDING_OFFERS_DIGEST: \${EMBEDDING_OFFERS_DIGEST:-}
3095
3100
  EMBEDDING_DEPLOYMENT_ALIAS: \${EMBEDDING_DEPLOYMENT_ALIAS:-}
3096
3101
  EMBEDDING_MODEL: \${EMBEDDING_MODEL:-}
3097
3102
  EMBEDDING_MODEL_VERSION: \${EMBEDDING_MODEL_VERSION:-}
@@ -3099,8 +3104,16 @@ var agentComposeService = ` tool-sandbox-init:
3099
3104
  EMBEDDING_NORMALIZED: \${EMBEDDING_NORMALIZED:-true}
3100
3105
  EMBEDDING_CAPABILITIES: \${EMBEDDING_CAPABILITIES:-embedding.text}
3101
3106
  EMBEDDING_MAX_UNITS: \${EMBEDDING_MAX_UNITS:-256}
3107
+ EMBEDDING_MAX_DURATION_SECONDS: \${EMBEDDING_MAX_DURATION_SECONDS:-600}
3108
+ EMBEDDING_MAX_INPUT_BYTES: \${EMBEDDING_MAX_INPUT_BYTES:-268435456}
3109
+ EMBEDDING_MAX_OUTPUT_BYTES: \${EMBEDDING_MAX_OUTPUT_BYTES:-268435456}
3110
+ EMBEDDING_MAX_ARTIFACTS: \${EMBEDDING_MAX_ARTIFACTS:-16}
3111
+ EMBEDDING_MAX_PIXELS_PER_ARTIFACT: \${EMBEDDING_MAX_PIXELS_PER_ARTIFACT:-100000000}
3102
3112
  EMBEDDING_RUN_ENCRYPTION_SECRET: \${EMBEDDING_RUN_ENCRYPTION_SECRET:-}
3103
3113
  EMBEDDING_MAX_CONCURRENT_RUNS: \${EMBEDDING_MAX_CONCURRENT_RUNS:-1}
3114
+ EMBEDDING_MAX_QUEUE_DEPTH: \${EMBEDDING_MAX_QUEUE_DEPTH:-0}
3115
+ EMBEDDING_READINESS_TIMEOUT_SECONDS: \${EMBEDDING_READINESS_TIMEOUT_SECONDS:-5}
3116
+ EMBEDDING_READINESS_TTL_SECONDS: \${EMBEDDING_READINESS_TTL_SECONDS:-60}
3104
3117
  EMBEDDING_RUN_TERMINAL_TTL_SECONDS: \${EMBEDDING_RUN_TERMINAL_TTL_SECONDS:-604800}
3105
3118
  volumes:
3106
3119
  - embedding-run-data:/app/data/embedding
@@ -3190,6 +3203,9 @@ EMBEDDING_RUN_ARTIFACT_URL=
3190
3203
  EMBEDDING_ADAPTER_URL=
3191
3204
  EMBEDDING_ADAPTER_TOKEN=
3192
3205
  EMBEDDING_DEPLOYMENT_KEY=
3206
+ EMBEDDING_DEPLOYMENT_IDENTITY=
3207
+ EMBEDDING_RELEASE_DIGEST=
3208
+ EMBEDDING_OFFERS_DIGEST=
3193
3209
  EMBEDDING_DEPLOYMENT_ALIAS=
3194
3210
  EMBEDDING_MODEL=
3195
3211
  EMBEDDING_MODEL_VERSION=
@@ -3197,8 +3213,16 @@ EMBEDDING_DIMENSIONS=
3197
3213
  # EMBEDDING_NORMALIZED=true
3198
3214
  # EMBEDDING_CAPABILITIES=embedding.text,embedding.image
3199
3215
  # EMBEDDING_MAX_UNITS=256
3216
+ # EMBEDDING_MAX_DURATION_SECONDS=600
3217
+ # EMBEDDING_MAX_INPUT_BYTES=268435456
3218
+ # EMBEDDING_MAX_OUTPUT_BYTES=268435456
3219
+ # EMBEDDING_MAX_ARTIFACTS=16
3220
+ # EMBEDDING_MAX_PIXELS_PER_ARTIFACT=100000000
3200
3221
  EMBEDDING_RUN_ENCRYPTION_SECRET=
3201
3222
  # EMBEDDING_MAX_CONCURRENT_RUNS=1
3223
+ # EMBEDDING_MAX_QUEUE_DEPTH=0
3224
+ # EMBEDDING_READINESS_TIMEOUT_SECONDS=5
3225
+ # EMBEDDING_READINESS_TTL_SECONDS=60
3202
3226
  # EMBEDDING_RUN_TERMINAL_TTL_SECONDS=604800
3203
3227
  QWEN_API_URL=
3204
3228
  QWEN_API_KEY=
@@ -7723,6 +7747,11 @@ var recipes = [
7723
7747
  id: "starter-0.5.2-to-0.6.0",
7724
7748
  from: "0.5.2",
7725
7749
  to: "0.6.0"
7750
+ },
7751
+ {
7752
+ id: "starter-0.6.0-to-0.6.1",
7753
+ from: "0.6.0",
7754
+ to: "0.6.1"
7726
7755
  }
7727
7756
  ];
7728
7757
  function resolveRecipeChain(sourceVersion, targetVersion) {
@@ -8774,7 +8803,7 @@ async function mergeTextFile(base, ours, theirs) {
8774
8803
  }
8775
8804
  return { content: new TextEncoder().encode(result2.stdout) };
8776
8805
  }
8777
- if (result2.code === 1) {
8806
+ if (result2.code > 0 && result2.code <= 127) {
8778
8807
  const resolved = resolveConservativeConflicts(result2.stdout);
8779
8808
  return resolved ? { content: new TextEncoder().encode(resolved) } : { conflict: "\u4E09\u65B9\u5408\u5E76\u5B58\u5728\u91CD\u53E0\u4FEE\u6539" };
8780
8809
  }
@@ -8957,6 +8986,11 @@ function mergeValue(base, ours, theirs, path2, conflicts) {
8957
8986
  if (files)
8958
8987
  return files;
8959
8988
  }
8989
+ if (path2.startsWith("package.json.scripts.")) {
8990
+ const script = mergeConjunctionScript(base, ours, theirs);
8991
+ if (script !== undefined)
8992
+ return script;
8993
+ }
8960
8994
  if (isObject(base) && isObject(ours) && isObject(theirs)) {
8961
8995
  const output = {};
8962
8996
  const keys = new Set([
@@ -8983,6 +9017,52 @@ function mergeAppendOnlyStringSet(base, ours, theirs) {
8983
9017
  }
8984
9018
  return [...new Set([...ours, ...theirs])].sort();
8985
9019
  }
9020
+ function mergeConjunctionScript(base, ours, theirs) {
9021
+ if (typeof base !== "string" || typeof ours !== "string" || typeof theirs !== "string") {
9022
+ return;
9023
+ }
9024
+ const baseCommands = conjunctionCommands(base);
9025
+ const oursCommands = conjunctionCommands(ours);
9026
+ const theirsCommands = conjunctionCommands(theirs);
9027
+ if (!baseCommands || !oursCommands || !theirsCommands || !unique(baseCommands) || !unique(oursCommands) || !unique(theirsCommands)) {
9028
+ return;
9029
+ }
9030
+ const oursGaps = additionsByBaseGap(baseCommands, oursCommands);
9031
+ const theirsGaps = additionsByBaseGap(baseCommands, theirsCommands);
9032
+ if (!oursGaps || !theirsGaps)
9033
+ return;
9034
+ const merged = [];
9035
+ for (let gap = 0;gap <= baseCommands.length; gap += 1) {
9036
+ const oursAdded = oursGaps[gap] ?? [];
9037
+ const theirsAdded = theirsGaps[gap] ?? [];
9038
+ if (oursAdded.length && theirsAdded.length && !equal(oursAdded, theirsAdded)) {
9039
+ return;
9040
+ }
9041
+ merged.push(...oursAdded.length ? oursAdded : theirsAdded);
9042
+ if (gap < baseCommands.length)
9043
+ merged.push(baseCommands[gap] ?? "");
9044
+ }
9045
+ return merged.join(" && ");
9046
+ }
9047
+ function conjunctionCommands(value) {
9048
+ const commands = value.split(" && ");
9049
+ return commands.length && commands.every((command) => command.length > 0) && commands.join(" && ") === value ? commands : undefined;
9050
+ }
9051
+ function additionsByBaseGap(base, branch) {
9052
+ const gaps = Array.from({ length: base.length + 1 }, () => []);
9053
+ let baseIndex = 0;
9054
+ for (const command of branch) {
9055
+ if (command === base[baseIndex]) {
9056
+ baseIndex += 1;
9057
+ } else {
9058
+ gaps[baseIndex]?.push(command);
9059
+ }
9060
+ }
9061
+ return baseIndex === base.length ? gaps : undefined;
9062
+ }
9063
+ function unique(values) {
9064
+ return new Set(values).size === values.length;
9065
+ }
8986
9066
  function isStringArray(value) {
8987
9067
  return Array.isArray(value) && value.every((item) => typeof item === "string");
8988
9068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "6149af754e862b43ca2fe344c354dcb07149bbc2",
4
- "generatorVersion": "0.6.0",
3
+ "templateCommit": "a035c896c8821fe9a2f3ef3aca9d30b7ec1fb096",
4
+ "generatorVersion": "0.6.1",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -15,23 +15,33 @@ from .adapter import EmbeddingAdapter
15
15
  from .config import EmbeddingConfig
16
16
  from .contracts import EmbeddingRequest, EmbeddingResponse, ErrorResponse
17
17
  from .error import SafeEmbeddingError
18
+ from .readiness import EmbeddingReadinessPort, EmbeddingReadinessProbe
18
19
  from .run_cipher import EmbeddingRunCipher
19
- from .run_service import EmbeddingRunService
20
+ from .run_service import EmbeddingExecutionPort, EmbeddingRunService
20
21
  from .run_store import EmbeddingRunStore, RunConflictError
21
22
 
22
23
 
23
24
  def create_app(
24
25
  config: EmbeddingConfig,
25
- adapter: EmbeddingAdapter | None = None,
26
+ adapter: EmbeddingExecutionPort | None = None,
27
+ readiness_probe: EmbeddingReadinessPort | None = None,
26
28
  ) -> FastAPI:
27
29
  app = FastAPI(title="Southwind Embedding Runtime", docs_url=None, redoc_url=None)
28
30
  runtime = adapter or EmbeddingAdapter(config)
31
+ readiness = readiness_probe or EmbeddingReadinessProbe(config)
29
32
  store = EmbeddingRunStore(
30
33
  config.run_store_path,
31
34
  EmbeddingRunCipher(config.run_encryption_secret),
32
35
  config.terminal_ttl_seconds,
33
36
  )
34
- service = EmbeddingRunService(runtime, store, config.max_concurrent_runs)
37
+
38
+ async def admit(request: EmbeddingRequest) -> bool:
39
+ inspection = await readiness.inspect()
40
+ return readiness.supports(inspection, request)
41
+
42
+ service = EmbeddingRunService(
43
+ runtime, store, config.max_concurrent_runs, admit=admit
44
+ )
35
45
 
36
46
  @asynccontextmanager
37
47
  async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
@@ -47,24 +57,9 @@ def create_app(
47
57
 
48
58
  @app.get("/health/ready")
49
59
  async def ready(_: None = Depends(authorize)) -> JSONResponse:
50
- if not await runtime.readiness():
51
- return JSONResponse(
52
- {"status": "unavailable", "capabilities": []},
53
- status_code=503,
54
- )
60
+ inspection = await readiness.inspect()
55
61
  return JSONResponse(
56
- {
57
- "status": "ready",
58
- "capabilities": list(config.capabilities),
59
- "limits": {"maxUnits": config.max_units},
60
- "deployment": {
61
- "alias": config.deployment_alias,
62
- "model": config.model,
63
- "version": config.version,
64
- "dimensions": config.dimensions,
65
- "normalized": config.normalized,
66
- },
67
- }
62
+ inspection.snapshot, status_code=200 if inspection.ready else 503
68
63
  )
69
64
 
70
65
  @app.post("/api/embeddings")
@@ -75,6 +70,12 @@ def create_app(
75
70
  ) -> EmbeddingResponse:
76
71
  if not idempotency_key.strip() or len(idempotency_key) > 160:
77
72
  raise HTTPException(status_code=400, detail="INVALID_INPUT")
73
+ if not await admit(request):
74
+ raise SafeEmbeddingError(
75
+ "CAPABILITY_UNAVAILABLE",
76
+ "Embedding Runtime 当前没有匹配的原子 offer",
77
+ True,
78
+ )
78
79
  try:
79
80
  async with asyncio.timeout(request.limits.max_duration_seconds):
80
81
  return await runtime.embed(request)
@@ -98,6 +99,12 @@ def create_app(
98
99
  or not valid_run_id(run_id)
99
100
  ):
100
101
  raise HTTPException(status_code=400, detail="INVALID_INPUT")
102
+ if not await admit(request):
103
+ raise SafeEmbeddingError(
104
+ "CAPABILITY_UNAVAILABLE",
105
+ "Embedding Runtime 当前没有匹配的原子 offer",
106
+ True,
107
+ )
101
108
  try:
102
109
  record = await service.start(request, idempotency_key, run_id)
103
110
  except RunConflictError:
@@ -1,9 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ipaddress
4
+ import re
4
5
  from dataclasses import dataclass
5
6
  from urllib.parse import urlparse
6
7
 
8
+ _STABLE_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
9
+ _DEPLOYMENT_IDENTITY = re.compile(
10
+ r"^windy-deployment-v1_[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-"
11
+ r"[89ab][a-f0-9]{3}-[a-f0-9]{12}$"
12
+ )
13
+ _DIGEST = re.compile(r"^sha256:[a-f0-9]{64}$")
14
+
7
15
 
8
16
  @dataclass(frozen=True)
9
17
  class EmbeddingConfig:
@@ -19,14 +27,35 @@ class EmbeddingConfig:
19
27
  dimensions: int
20
28
  normalized: bool
21
29
  capabilities: tuple[str, ...]
30
+ deployment_identity: str | None = None
31
+ release_digest: str | None = None
32
+ offers_digest: str | None = None
22
33
  max_units: int = 256
34
+ max_duration_seconds: int = 600
35
+ max_input_bytes: int = 268_435_456
36
+ max_output_bytes: int = 268_435_456
37
+ max_artifacts: int = 16
38
+ max_pixels_per_artifact: int = 100_000_000
23
39
  connect_timeout_seconds: float = 5
24
40
  request_timeout_seconds: float = 120
25
41
  run_store_path: str = "/app/data/embedding/runs.sqlite3"
26
42
  run_encryption_secret: str = ""
27
43
  max_concurrent_runs: int = 1
44
+ max_queue_depth: int = 0
45
+ readiness_timeout_seconds: float = 5
46
+ readiness_ttl_seconds: int = 60
28
47
  terminal_ttl_seconds: int = 7 * 24 * 60 * 60
29
48
 
49
+ @property
50
+ def readiness_configured(self) -> bool:
51
+ return bool(
52
+ self.adapter_url
53
+ and self.deployment_key
54
+ and self.deployment_identity
55
+ and self.release_digest
56
+ and self.offers_digest
57
+ )
58
+
30
59
  @classmethod
31
60
  def from_environment(cls, environment: dict[str, str]) -> EmbeddingConfig:
32
61
  artifact_url = environment.get(
@@ -53,8 +82,18 @@ class EmbeddingConfig:
53
82
  adapter_url=adapter_url,
54
83
  adapter_token=environment.get("EMBEDDING_ADAPTER_TOKEN", "").strip()
55
84
  or None,
56
- deployment_key=environment.get("EMBEDDING_DEPLOYMENT_KEY", "").strip()
57
- or None,
85
+ deployment_key=optional_match(
86
+ environment, "EMBEDDING_DEPLOYMENT_KEY", _STABLE_KEY
87
+ ),
88
+ deployment_identity=optional_match(
89
+ environment, "EMBEDDING_DEPLOYMENT_IDENTITY", _DEPLOYMENT_IDENTITY
90
+ ),
91
+ release_digest=optional_match(
92
+ environment, "EMBEDDING_RELEASE_DIGEST", _DIGEST
93
+ ),
94
+ offers_digest=optional_match(
95
+ environment, "EMBEDDING_OFFERS_DIGEST", _DIGEST
96
+ ),
58
97
  deployment_alias=required(environment, "EMBEDDING_DEPLOYMENT_ALIAS"),
59
98
  model=required(environment, "EMBEDDING_MODEL"),
60
99
  version=required(environment, "EMBEDDING_MODEL_VERSION"),
@@ -75,6 +114,19 @@ class EmbeddingConfig:
75
114
  )
76
115
  ),
77
116
  max_units=positive_int(environment, "EMBEDDING_MAX_UNITS", 256),
117
+ max_duration_seconds=positive_int(
118
+ environment, "EMBEDDING_MAX_DURATION_SECONDS", 600
119
+ ),
120
+ max_input_bytes=positive_int(
121
+ environment, "EMBEDDING_MAX_INPUT_BYTES", 268_435_456
122
+ ),
123
+ max_output_bytes=positive_int(
124
+ environment, "EMBEDDING_MAX_OUTPUT_BYTES", 268_435_456
125
+ ),
126
+ max_artifacts=positive_int(environment, "EMBEDDING_MAX_ARTIFACTS", 16),
127
+ max_pixels_per_artifact=positive_int(
128
+ environment, "EMBEDDING_MAX_PIXELS_PER_ARTIFACT", 100_000_000
129
+ ),
78
130
  run_store_path=environment.get(
79
131
  "EMBEDDING_RUN_STORE_PATH", "/app/data/embedding/runs.sqlite3"
80
132
  ).strip(),
@@ -84,6 +136,15 @@ class EmbeddingConfig:
84
136
  max_concurrent_runs=positive_int(
85
137
  environment, "EMBEDDING_MAX_CONCURRENT_RUNS", 1
86
138
  ),
139
+ max_queue_depth=non_negative_int(
140
+ environment, "EMBEDDING_MAX_QUEUE_DEPTH", 0
141
+ ),
142
+ readiness_timeout_seconds=bounded_positive_float(
143
+ environment, "EMBEDDING_READINESS_TIMEOUT_SECONDS", 5, 30
144
+ ),
145
+ readiness_ttl_seconds=bounded_positive_int(
146
+ environment, "EMBEDDING_READINESS_TTL_SECONDS", 60, 300
147
+ ),
87
148
  terminal_ttl_seconds=positive_int(
88
149
  environment, "EMBEDDING_RUN_TERMINAL_TTL_SECONDS", 7 * 24 * 60 * 60
89
150
  ),
@@ -120,8 +181,54 @@ def boolean(environment: dict[str, str], key: str, default: bool) -> bool:
120
181
 
121
182
 
122
183
  def required_capabilities(value: tuple[str, ...]) -> tuple[str, ...]:
123
- if not value:
124
- raise ValueError("EMBEDDING_CAPABILITIES 不能为空")
184
+ if (
185
+ not value
186
+ or len(value) != len(set(value))
187
+ or any(not _STABLE_KEY.fullmatch(item) for item in value)
188
+ ):
189
+ raise ValueError("EMBEDDING_CAPABILITIES 无效")
190
+ return value
191
+
192
+
193
+ def optional_match(
194
+ environment: dict[str, str], key: str, pattern: re.Pattern[str]
195
+ ) -> str | None:
196
+ value = environment.get(key, "").strip()
197
+ if value and not pattern.fullmatch(value):
198
+ raise ValueError(f"{key} 格式无效")
199
+ return value or None
200
+
201
+
202
+ def non_negative_int(environment: dict[str, str], key: str, default: int) -> int:
203
+ raw = environment.get(key, str(default)).strip()
204
+ try:
205
+ value = int(raw)
206
+ except ValueError:
207
+ raise ValueError(f"{key} 必须是非负整数") from None
208
+ if value < 0:
209
+ raise ValueError(f"{key} 必须是非负整数")
210
+ return value
211
+
212
+
213
+ def bounded_positive_int(
214
+ environment: dict[str, str], key: str, default: int, maximum: int
215
+ ) -> int:
216
+ value = positive_int(environment, key, default)
217
+ if value > maximum:
218
+ raise ValueError(f"{key} 不能超过 {maximum}")
219
+ return value
220
+
221
+
222
+ def bounded_positive_float(
223
+ environment: dict[str, str], key: str, default: float, maximum: float
224
+ ) -> float:
225
+ raw = environment.get(key, str(default)).strip()
226
+ try:
227
+ value = float(raw)
228
+ except ValueError:
229
+ raise ValueError(f"{key} 必须是正数") from None
230
+ if value <= 0 or value > maximum:
231
+ raise ValueError(f"{key} 必须大于 0 且不超过 {maximum:g}")
125
232
  return value
126
233
 
127
234
 
@@ -66,6 +66,18 @@ class ProviderDataPolicy(CanonicalModel):
66
66
  envelope_fields: list[dict[str, JsonValue]]
67
67
 
68
68
 
69
+ class ReadinessPinV1(CanonicalModel):
70
+ key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
71
+ identity: str = Field(
72
+ pattern=(
73
+ r"^windy-deployment-v1_[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-"
74
+ r"[89ab][a-f0-9]{3}-[a-f0-9]{12}$"
75
+ )
76
+ )
77
+ release_digest: str = Field(pattern=r"^sha256:[a-f0-9]{64}$")
78
+ offers_digest: str = Field(pattern=r"^sha256:[a-f0-9]{64}$")
79
+
80
+
69
81
  class EmbeddingRequest(CanonicalModel):
70
82
  kind: Literal["embedding"]
71
83
  operation_key: str = Field(min_length=1)
@@ -77,6 +89,7 @@ class EmbeddingRequest(CanonicalModel):
77
89
  limits: RuntimeLimits
78
90
  provider_egress: ProviderEgress
79
91
  provider_data_policy: ProviderDataPolicy | None = None
92
+ readiness_pin: ReadinessPinV1 | None = None
80
93
 
81
94
 
82
95
  class EmbeddingVector(CanonicalModel):
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+
7
+ import httpx
8
+
9
+ from .config import EmbeddingConfig
10
+ from .readiness_contracts import (
11
+ AdapterEmbeddingReadinessV1,
12
+ compute_offers_digest,
13
+ compute_release_digest,
14
+ final_offers,
15
+ )
16
+ from .readiness_http import get_json
17
+
18
+
19
+ async def compute_pins(
20
+ config: EmbeddingConfig,
21
+ transport: httpx.AsyncBaseTransport | None = None,
22
+ ) -> dict[str, str]:
23
+ if not config.deployment_key or not config.deployment_identity:
24
+ raise ValueError("Embedding deployment key/identity 缺失")
25
+ if not config.adapter_url:
26
+ raise ValueError("Embedding Adapter 未配置")
27
+ adapter = AdapterEmbeddingReadinessV1.model_validate(
28
+ await get_json(
29
+ f"{config.adapter_url.rstrip('/')}/v1/embedding/readiness",
30
+ config.adapter_token,
31
+ config.readiness_timeout_seconds,
32
+ transport,
33
+ )
34
+ )
35
+ deployment = adapter.deployment
36
+ if (
37
+ deployment.model != config.model
38
+ or deployment.version != config.version
39
+ or deployment.dimensions != config.dimensions
40
+ or deployment.normalized != config.normalized
41
+ ):
42
+ raise ValueError("Embedding Adapter deployment 漂移")
43
+ offers = final_offers(config, adapter)
44
+ return {
45
+ "deploymentKey": config.deployment_key,
46
+ "deploymentIdentity": config.deployment_identity,
47
+ "releaseDigest": compute_release_digest(adapter),
48
+ "offersDigest": compute_offers_digest(offers),
49
+ }
50
+
51
+
52
+ async def main() -> None:
53
+ try:
54
+ pins = await compute_pins(EmbeddingConfig.from_environment(dict(os.environ)))
55
+ except Exception as error:
56
+ message = "Embedding readiness full probe 未通过,未写入 pins"
57
+ raise SystemExit(message) from error
58
+ print(json.dumps(pins, separators=(",", ":"), sort_keys=True))
59
+
60
+
61
+ if __name__ == "__main__":
62
+ asyncio.run(main())