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
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import Protocol
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from .config import EmbeddingConfig
14
+ from .contracts import EmbeddingRequest
15
+ from .readiness_contracts import (
16
+ AdapterEmbeddingReadinessV1,
17
+ compute_offers_digest,
18
+ compute_release_digest,
19
+ final_offers,
20
+ supports_offer,
21
+ )
22
+ from .readiness_http import ProbeFailure, ProbeFailureCode, get_json
23
+
24
+ type Snapshot = dict[str, object]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EmbeddingReadinessInspection:
29
+ snapshot: Snapshot
30
+ offers: tuple[dict[str, object], ...] = ()
31
+ valid_until: datetime | None = None
32
+
33
+ @property
34
+ def ready(self) -> bool:
35
+ return self.snapshot.get("status") == "ready" and bool(self.offers)
36
+
37
+
38
+ class EmbeddingReadinessPort(Protocol):
39
+ async def inspect(self) -> EmbeddingReadinessInspection: ...
40
+
41
+ def supports(
42
+ self,
43
+ inspection: EmbeddingReadinessInspection,
44
+ request: EmbeddingRequest,
45
+ ) -> bool: ...
46
+
47
+
48
+ class EmbeddingReadinessProbe:
49
+ def __init__(
50
+ self,
51
+ config: EmbeddingConfig,
52
+ transport: httpx.AsyncBaseTransport | None = None,
53
+ wall_clock: Callable[[], datetime] = lambda: datetime.now(UTC),
54
+ monotonic_clock: Callable[[], float] = time.monotonic,
55
+ ) -> None:
56
+ self.config = config
57
+ self.transport = transport
58
+ self.wall_clock = wall_clock
59
+ self.monotonic_clock = monotonic_clock
60
+
61
+ async def inspect(self) -> EmbeddingReadinessInspection:
62
+ checked_at = self._now()
63
+ started = self.monotonic_clock()
64
+ adapter_url = self.config.adapter_url
65
+ if not self.config.readiness_configured or not adapter_url:
66
+ return EmbeddingReadinessInspection(self._not_configured(checked_at))
67
+ try:
68
+ async with asyncio.timeout(self.config.readiness_timeout_seconds):
69
+ adapter = AdapterEmbeddingReadinessV1.model_validate(
70
+ await get_json(
71
+ f"{adapter_url.rstrip('/')}/v1/embedding/readiness",
72
+ self.config.adapter_token,
73
+ self.config.readiness_timeout_seconds,
74
+ self.transport,
75
+ )
76
+ )
77
+ self._validate_adapter(adapter)
78
+ offers = final_offers(self.config, adapter)
79
+ offers_digest = compute_offers_digest(offers)
80
+ release_digest = compute_release_digest(adapter)
81
+ if (
82
+ offers_digest != self.config.offers_digest
83
+ or release_digest != self.config.release_digest
84
+ ):
85
+ raise ProbeFailure("INVALID_RESPONSE", False)
86
+ except TimeoutError:
87
+ return self._failed(checked_at, started, "TIMEOUT", True)
88
+ except httpx.TimeoutException:
89
+ return self._failed(checked_at, started, "TIMEOUT", True)
90
+ except httpx.ConnectError:
91
+ return self._failed(checked_at, started, "CONNECTION_FAILED", True)
92
+ except httpx.HTTPError:
93
+ return self._failed(checked_at, started, "CONNECTION_FAILED", True)
94
+ except ProbeFailure as failure:
95
+ return self._failed(checked_at, started, failure.code, failure.retryable)
96
+ except (ValidationError, ValueError, KeyError, TypeError, AssertionError):
97
+ return self._failed(checked_at, started, "INVALID_RESPONSE", False)
98
+
99
+ observed_at = self._now()
100
+ valid_until = checked_at + timedelta(seconds=self.config.readiness_ttl_seconds)
101
+ deployment = self._deployment(release_digest, offers_digest)
102
+ if observed_at >= valid_until:
103
+ return EmbeddingReadinessInspection(
104
+ self._stale(checked_at, observed_at, valid_until, deployment)
105
+ )
106
+ snapshot: Snapshot = {
107
+ "schemaVersion": 1,
108
+ "status": "ready",
109
+ "configured": True,
110
+ "runtimeKind": "embedding",
111
+ "observedAt": canonical_time(observed_at),
112
+ "downstreamProbe": {
113
+ "status": "passed",
114
+ "checkedAt": canonical_time(checked_at),
115
+ "validUntil": canonical_time(valid_until),
116
+ "latencyMs": latency_ms(started, self.monotonic_clock()),
117
+ "deploymentIdentity": deployment["identity"],
118
+ "releaseDigest": deployment["releaseDigest"],
119
+ "offersDigest": deployment["offersDigest"],
120
+ },
121
+ "deployment": deployment,
122
+ "offers": offers,
123
+ "unavailableReasons": [],
124
+ }
125
+ return EmbeddingReadinessInspection(snapshot, tuple(offers), valid_until)
126
+
127
+ def supports(
128
+ self,
129
+ inspection: EmbeddingReadinessInspection,
130
+ request: EmbeddingRequest,
131
+ ) -> bool:
132
+ pin = request.readiness_pin
133
+ deployment = inspection.snapshot.get("deployment")
134
+ if (
135
+ not inspection.ready
136
+ or inspection.valid_until is None
137
+ or self._now() >= inspection.valid_until
138
+ or not isinstance(deployment, dict)
139
+ or (pin is not None and pin.model_dump(by_alias=True) != deployment)
140
+ ):
141
+ return False
142
+ return any(supports_offer(offer, request) for offer in inspection.offers)
143
+
144
+ def _validate_adapter(self, adapter: AdapterEmbeddingReadinessV1) -> None:
145
+ deployment = adapter.deployment
146
+ if (
147
+ deployment.model != self.config.model
148
+ or deployment.version != self.config.version
149
+ or deployment.dimensions != self.config.dimensions
150
+ or deployment.normalized != self.config.normalized
151
+ ):
152
+ raise ProbeFailure("INVALID_RESPONSE", False)
153
+
154
+ def _deployment(self, release_digest: str, offers_digest: str) -> dict[str, str]:
155
+ values = (
156
+ self.config.deployment_key,
157
+ self.config.deployment_identity,
158
+ self.config.release_digest,
159
+ self.config.offers_digest,
160
+ )
161
+ if not all(values):
162
+ raise ValueError("Embedding readiness 配置缺失")
163
+ return {
164
+ "key": str(values[0]),
165
+ "identity": str(values[1]),
166
+ "releaseDigest": release_digest,
167
+ "offersDigest": offers_digest,
168
+ }
169
+
170
+ def _not_configured(self, observed_at: datetime) -> Snapshot:
171
+ return {
172
+ "schemaVersion": 1,
173
+ "status": "unavailable",
174
+ "configured": False,
175
+ "runtimeKind": "embedding",
176
+ "observedAt": canonical_time(observed_at),
177
+ "downstreamProbe": {"status": "not-run", "reason": "NOT_CONFIGURED"},
178
+ "deployment": None,
179
+ "offers": [],
180
+ "unavailableReasons": [
181
+ {"code": "RUNTIME_NOT_CONFIGURED", "retryable": True}
182
+ ],
183
+ }
184
+
185
+ def _failed(
186
+ self,
187
+ checked_at: datetime,
188
+ started: float,
189
+ code: ProbeFailureCode,
190
+ retryable: bool,
191
+ ) -> EmbeddingReadinessInspection:
192
+ return EmbeddingReadinessInspection(
193
+ {
194
+ "schemaVersion": 1,
195
+ "status": "unavailable",
196
+ "configured": True,
197
+ "runtimeKind": "embedding",
198
+ "observedAt": canonical_time(self._now()),
199
+ "downstreamProbe": {
200
+ "status": "failed",
201
+ "checkedAt": canonical_time(checked_at),
202
+ "latencyMs": latency_ms(started, self.monotonic_clock()),
203
+ "failureCode": code,
204
+ "retryable": retryable,
205
+ },
206
+ "deployment": None,
207
+ "offers": [],
208
+ "unavailableReasons": [
209
+ {
210
+ "code": "DOWNSTREAM_PROBE_FAILED",
211
+ "retryable": retryable,
212
+ "subject": code,
213
+ }
214
+ ],
215
+ }
216
+ )
217
+
218
+ def _stale(
219
+ self,
220
+ checked_at: datetime,
221
+ observed_at: datetime,
222
+ valid_until: datetime,
223
+ deployment: dict[str, str],
224
+ ) -> Snapshot:
225
+ return {
226
+ "schemaVersion": 1,
227
+ "status": "unavailable",
228
+ "configured": True,
229
+ "runtimeKind": "embedding",
230
+ "observedAt": canonical_time(observed_at),
231
+ "downstreamProbe": {
232
+ "status": "passed",
233
+ "checkedAt": canonical_time(checked_at),
234
+ "validUntil": canonical_time(valid_until),
235
+ "latencyMs": 0,
236
+ "deploymentIdentity": deployment["identity"],
237
+ "releaseDigest": deployment["releaseDigest"],
238
+ "offersDigest": deployment["offersDigest"],
239
+ },
240
+ "deployment": deployment,
241
+ "offers": [],
242
+ "unavailableReasons": [{"code": "PROBE_STALE", "retryable": True}],
243
+ }
244
+
245
+ def _now(self) -> datetime:
246
+ value = self.wall_clock()
247
+ if value.tzinfo is None:
248
+ raise ValueError("Embedding readiness wall clock 缺少时区")
249
+ return value.astimezone(UTC)
250
+
251
+
252
+ def canonical_time(value: datetime) -> str:
253
+ return (
254
+ value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
255
+ )
256
+
257
+
258
+ def latency_ms(started: float, finished: float) -> int:
259
+ return max(0, round((finished - started) * 1000))
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ from typing import Literal, Self
8
+
9
+ from pydantic import Field, field_validator, model_validator
10
+
11
+ from .config import EmbeddingConfig
12
+ from .contracts import CanonicalModel, EmbeddingRequest
13
+
14
+ MAX_SAFE_INTEGER_V1 = 2**53 - 1
15
+ EMBEDDING_RUNTIME_RELEASE_V1 = "southwind-embedding-runtime/readiness-v1"
16
+ _STABLE_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
17
+ _IDENTITY = re.compile(
18
+ r"^windy-deployment-v1_[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-"
19
+ r"[89ab][a-f0-9]{3}-[a-f0-9]{12}$"
20
+ )
21
+ _DIGEST = re.compile(r"^sha256:[a-f0-9]{64}$")
22
+ _LIMIT_KEYS = (
23
+ "maxDurationSeconds",
24
+ "maxInputBytes",
25
+ "maxOutputBytes",
26
+ "maxUnits",
27
+ "maxArtifacts",
28
+ "maxPixelsPerArtifact",
29
+ "maxConcurrentRuns",
30
+ "maxQueueDepth",
31
+ )
32
+ type Modality = Literal["text", "image", "audio", "video", "document", "embedding"]
33
+
34
+
35
+ class EmbeddingReadinessLimitsV1(CanonicalModel):
36
+ max_duration_seconds: int = Field(ge=1, le=MAX_SAFE_INTEGER_V1)
37
+ max_input_bytes: int = Field(ge=1, le=MAX_SAFE_INTEGER_V1)
38
+ max_output_bytes: int = Field(ge=1, le=MAX_SAFE_INTEGER_V1)
39
+ max_units: int = Field(ge=1, le=MAX_SAFE_INTEGER_V1)
40
+ max_artifacts: int | None = Field(default=None, ge=1, le=MAX_SAFE_INTEGER_V1)
41
+ max_pixels_per_artifact: int | None = Field(
42
+ default=None, ge=1, le=MAX_SAFE_INTEGER_V1
43
+ )
44
+ max_concurrent_runs: int = Field(ge=1, le=MAX_SAFE_INTEGER_V1)
45
+ max_queue_depth: int = Field(ge=0, le=MAX_SAFE_INTEGER_V1)
46
+
47
+ @field_validator("*", mode="before")
48
+ @classmethod
49
+ def normalize_integer_numbers(cls, value: object) -> object:
50
+ if isinstance(value, float) and math.isfinite(value) and value.is_integer():
51
+ return int(value)
52
+ return value
53
+
54
+
55
+ class AdapterEmbeddingOfferV1(CanonicalModel):
56
+ variant: str = Field(pattern=_STABLE_KEY.pattern)
57
+ capability_version: str = Field(pattern=_STABLE_KEY.pattern)
58
+ capabilities: list[str] = Field(min_length=1, max_length=32)
59
+ input_modalities: list[Modality] = Field(min_length=1, max_length=2)
60
+ output_modalities: list[Modality] = Field(min_length=1, max_length=1)
61
+ limits: EmbeddingReadinessLimitsV1
62
+
63
+ @model_validator(mode="after")
64
+ def validate_atomic_offer(self) -> Self:
65
+ if any(not _STABLE_KEY.fullmatch(value) for value in self.capabilities):
66
+ raise ValueError("Embedding Adapter capability key 无效")
67
+ if len(self.capabilities) != len(set(self.capabilities)):
68
+ raise ValueError("Embedding Adapter capability 重复")
69
+ if len(self.input_modalities) != len(set(self.input_modalities)):
70
+ raise ValueError("Embedding Adapter 输入模态重复")
71
+ if len(self.output_modalities) != len(set(self.output_modalities)):
72
+ raise ValueError("Embedding Adapter 输出模态重复")
73
+ if not set(self.input_modalities).issubset({"text", "image"}):
74
+ raise ValueError("Embedding Adapter 输入模态无效")
75
+ if set(self.output_modalities) != {"embedding"}:
76
+ raise ValueError("Embedding Adapter 输出必须是 embedding")
77
+ return self
78
+
79
+
80
+ class AdapterEmbeddingDeploymentV1(CanonicalModel):
81
+ identity: str = Field(pattern=_IDENTITY.pattern)
82
+ release_digest: str = Field(pattern=_DIGEST.pattern)
83
+ model: str = Field(min_length=1, max_length=200)
84
+ version: str = Field(min_length=1, max_length=200)
85
+ dimensions: int = Field(ge=1, le=65_536)
86
+ normalized: bool
87
+
88
+
89
+ class AdapterEmbeddingReadinessV1(CanonicalModel):
90
+ contract_version: Literal[1]
91
+ status: Literal["ready"]
92
+ runtime_kind: Literal["embedding-adapter"]
93
+ deployment: AdapterEmbeddingDeploymentV1
94
+ offers: list[AdapterEmbeddingOfferV1] = Field(min_length=1, max_length=16)
95
+
96
+ @model_validator(mode="after")
97
+ def validate_variants(self) -> Self:
98
+ variants = [offer.variant for offer in self.offers]
99
+ if len(variants) != len(set(variants)):
100
+ raise ValueError("Embedding Adapter variant 重复")
101
+ return self
102
+
103
+
104
+ def final_offers(
105
+ config: EmbeddingConfig, adapter: AdapterEmbeddingReadinessV1
106
+ ) -> list[dict[str, object]]:
107
+ configured = set(config.capabilities)
108
+ offers: list[dict[str, object]] = []
109
+ for downstream in adapter.offers:
110
+ if not set(downstream.capabilities).issubset(configured):
111
+ raise ValueError("Embedding Adapter capabilities 超出配置")
112
+ limits = downstream.limits
113
+ current: dict[str, object] = {
114
+ "variant": downstream.variant,
115
+ "capabilities": list(downstream.capabilities),
116
+ "inputModalities": list(downstream.input_modalities),
117
+ "outputModalities": ["embedding"],
118
+ "limits": {
119
+ "maxDurationSeconds": min(
120
+ config.max_duration_seconds, limits.max_duration_seconds
121
+ ),
122
+ "maxInputBytes": min(config.max_input_bytes, limits.max_input_bytes),
123
+ "maxOutputBytes": min(config.max_output_bytes, limits.max_output_bytes),
124
+ "maxUnits": min(config.max_units, limits.max_units),
125
+ "maxConcurrentRuns": min(
126
+ config.max_concurrent_runs, limits.max_concurrent_runs
127
+ ),
128
+ "maxQueueDepth": min(config.max_queue_depth, limits.max_queue_depth),
129
+ },
130
+ }
131
+ bounded = current["limits"]
132
+ assert isinstance(bounded, dict)
133
+ if "image" in downstream.input_modalities:
134
+ bounded["maxArtifacts"] = min(
135
+ config.max_artifacts, limits.max_artifacts or config.max_artifacts
136
+ )
137
+ bounded["maxPixelsPerArtifact"] = min(
138
+ config.max_pixels_per_artifact,
139
+ limits.max_pixels_per_artifact or config.max_pixels_per_artifact,
140
+ )
141
+ offers.append(current)
142
+ return offers
143
+
144
+
145
+ def supports_offer(offer: dict[str, object], request: EmbeddingRequest) -> bool:
146
+ limits = offer.get("limits")
147
+ if not isinstance(limits, dict):
148
+ return False
149
+ artifacts = any(item.source is not None for item in request.items)
150
+ return bool(
151
+ set(request.required_capabilities).issubset(strings(offer["capabilities"]))
152
+ and set(request.input_modalities).issubset(strings(offer["inputModalities"]))
153
+ and number(limits, "maxDurationSeconds") >= request.limits.max_duration_seconds
154
+ and number(limits, "maxInputBytes") >= request.limits.max_input_bytes
155
+ and number(limits, "maxOutputBytes") >= request.limits.max_output_bytes
156
+ and number(limits, "maxUnits") >= request.limits.max_units
157
+ and (
158
+ not artifacts
159
+ or (
160
+ request.limits.max_artifacts is not None
161
+ and request.limits.max_pixels_per_artifact is not None
162
+ and number(limits, "maxArtifacts") >= request.limits.max_artifacts
163
+ and number(limits, "maxPixelsPerArtifact")
164
+ >= request.limits.max_pixels_per_artifact
165
+ )
166
+ )
167
+ )
168
+
169
+
170
+ def compute_offers_digest(offers: list[dict[str, object]]) -> str:
171
+ if not 0 < len(offers) <= 16:
172
+ raise ValueError("Embedding readiness offer 数量无效")
173
+ canonical = sorted(
174
+ (canonical_offer(item) for item in offers),
175
+ key=lambda item: str(item.get("variant")),
176
+ )
177
+ variants = [item.get("variant") for item in canonical]
178
+ if len(variants) != len(set(variants)):
179
+ raise ValueError("Embedding readiness variant 重复")
180
+ encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode()
181
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
182
+
183
+
184
+ def compute_release_digest(adapter: AdapterEmbeddingReadinessV1) -> str:
185
+ payload = {
186
+ "adapter": adapter.deployment.model_dump(by_alias=True),
187
+ "capabilityVersions": sorted(
188
+ [
189
+ {"variant": item.variant, "capabilityVersion": item.capability_version}
190
+ for item in adapter.offers
191
+ ],
192
+ key=lambda item: item["variant"],
193
+ ),
194
+ "runtimeRelease": EMBEDDING_RUNTIME_RELEASE_V1,
195
+ }
196
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
197
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
198
+
199
+
200
+ def canonical_offer(value: dict[str, object]) -> dict[str, object]:
201
+ limits = value.get("limits")
202
+ if not isinstance(limits, dict):
203
+ raise ValueError("Embedding readiness offer 无效")
204
+ return {
205
+ "variant": value.get("variant"),
206
+ "capabilities": sorted(strings(value.get("capabilities"))),
207
+ "inputModalities": sorted(strings(value.get("inputModalities"))),
208
+ "outputModalities": sorted(strings(value.get("outputModalities"))),
209
+ "limits": {key: limits[key] for key in _LIMIT_KEYS if key in limits},
210
+ }
211
+
212
+
213
+ def strings(value: object) -> set[str]:
214
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
215
+ raise ValueError("Embedding readiness list 无效")
216
+ return set(value)
217
+
218
+
219
+ def number(value: dict[str, object], key: str) -> int:
220
+ current = value.get(key)
221
+ return current if isinstance(current, int) and not isinstance(current, bool) else -1
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Literal, cast
5
+
6
+ import httpx
7
+
8
+ MAX_READINESS_BODY_BYTES = 64 * 1024
9
+ type ProbeFailureCode = Literal[
10
+ "AUTHENTICATION_FAILED",
11
+ "CONNECTION_FAILED",
12
+ "INVALID_RESPONSE",
13
+ "RESOURCE_UNAVAILABLE",
14
+ "TIMEOUT",
15
+ "UNHEALTHY",
16
+ ]
17
+
18
+
19
+ class ProbeFailure(Exception):
20
+ def __init__(self, code: ProbeFailureCode, retryable: bool) -> None:
21
+ super().__init__(code)
22
+ self.code = code
23
+ self.retryable = retryable
24
+
25
+
26
+ async def get_json(
27
+ url: str,
28
+ token: str | None,
29
+ timeout_seconds: float,
30
+ transport: httpx.AsyncBaseTransport | None,
31
+ ) -> object:
32
+ headers = {"authorization": f"Bearer {token}"} if token else {}
33
+ async with httpx.AsyncClient(
34
+ timeout=httpx.Timeout(timeout_seconds),
35
+ transport=transport,
36
+ follow_redirects=False,
37
+ ) as client:
38
+ async with client.stream("GET", url, headers=headers) as response:
39
+ if response.status_code != 200:
40
+ raise response_failure(response.status_code)
41
+ media_type = response.headers.get("content-type", "").split(";", 1)[0]
42
+ if media_type.strip().lower() != "application/json":
43
+ raise ProbeFailure("INVALID_RESPONSE", False)
44
+ if response.headers.get("content-encoding"):
45
+ raise ProbeFailure("INVALID_RESPONSE", False)
46
+ declared = response.headers.get("content-length")
47
+ if declared and (
48
+ not declared.isdigit() or int(declared) > MAX_READINESS_BODY_BYTES
49
+ ):
50
+ raise ProbeFailure("INVALID_RESPONSE", False)
51
+ body = bytearray()
52
+ async for chunk in response.aiter_bytes():
53
+ if len(chunk) > MAX_READINESS_BODY_BYTES - len(body):
54
+ raise ProbeFailure("INVALID_RESPONSE", False)
55
+ body.extend(chunk)
56
+ if declared and int(declared) != len(body):
57
+ raise ProbeFailure("INVALID_RESPONSE", False)
58
+ try:
59
+ return cast(
60
+ object,
61
+ json.loads(
62
+ body.decode("utf-8"),
63
+ object_pairs_hook=unique_object,
64
+ parse_constant=reject_constant,
65
+ ),
66
+ )
67
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
68
+ raise ProbeFailure("INVALID_RESPONSE", False) from None
69
+
70
+
71
+ def response_failure(status: int) -> ProbeFailure:
72
+ if status in {401, 403}:
73
+ return ProbeFailure("AUTHENTICATION_FAILED", False)
74
+ if status in {408, 504}:
75
+ return ProbeFailure("TIMEOUT", True)
76
+ if status == 429 or status >= 500:
77
+ return ProbeFailure("RESOURCE_UNAVAILABLE", True)
78
+ return ProbeFailure("UNHEALTHY", False)
79
+
80
+
81
+ def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
82
+ result: dict[str, object] = {}
83
+ for key, value in pairs:
84
+ if key in result:
85
+ raise ValueError("duplicate key")
86
+ result[key] = value
87
+ return result
88
+
89
+
90
+ def reject_constant(_value: str) -> object:
91
+ raise ValueError("non-finite number")
@@ -3,25 +3,36 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import hashlib
5
5
  import json
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Protocol
6
8
 
7
- from .adapter import EmbeddingAdapter
8
9
  from .config import EmbeddingConfig
9
- from .contracts import EmbeddingRequest
10
+ from .contracts import EmbeddingRequest, EmbeddingResponse
10
11
  from .error import SafeEmbeddingError
11
12
  from .run_store import EmbeddingRunRecord, EmbeddingRunStore, failure
12
13
 
13
14
 
15
+ class EmbeddingExecutionPort(Protocol):
16
+ config: EmbeddingConfig
17
+
18
+ async def embed(
19
+ self, request: EmbeddingRequest, run_id: str | None = None
20
+ ) -> EmbeddingResponse: ...
21
+
22
+
14
23
  class EmbeddingRunService:
15
24
  def __init__(
16
25
  self,
17
- adapter: EmbeddingAdapter,
26
+ adapter: EmbeddingExecutionPort,
18
27
  store: EmbeddingRunStore,
19
28
  max_concurrent_runs: int,
29
+ admit: Callable[[EmbeddingRequest], Awaitable[bool]] | None = None,
20
30
  ) -> None:
21
31
  self.adapter = adapter
22
32
  self.store = store
23
33
  self._tasks: dict[str, asyncio.Task[None]] = {}
24
34
  self._queue = asyncio.Semaphore(max_concurrent_runs)
35
+ self._admit = admit
25
36
 
26
37
  async def start(
27
38
  self, request: EmbeddingRequest, idempotency_key: str, run_id: str
@@ -76,6 +87,12 @@ class EmbeddingRunService:
76
87
  "Embedding Deployment 已漂移",
77
88
  False,
78
89
  )
90
+ if self._admit and not await self._admit(request):
91
+ raise SafeEmbeddingError(
92
+ "CAPABILITY_UNAVAILABLE",
93
+ "Embedding Runtime readiness 已漂移",
94
+ True,
95
+ )
79
96
  await self.store.append(run_id, "run.started")
80
97
  async with asyncio.timeout(request.limits.max_duration_seconds):
81
98
  output = await self.adapter.embed(request, run_id)
@@ -127,4 +144,7 @@ def deployment_pin(config: EmbeddingConfig) -> dict[str, object]:
127
144
  "normalized": config.normalized,
128
145
  "maxUnits": config.max_units,
129
146
  "capabilities": list(config.capabilities),
147
+ "deploymentIdentity": config.deployment_identity,
148
+ "releaseDigest": config.release_digest,
149
+ "offersDigest": config.offers_digest,
130
150
  }
@@ -36,3 +36,16 @@ def test_rejects_ambiguous_normalization() -> None:
36
36
 
37
37
  with pytest.raises(ValueError, match="true 或 false"):
38
38
  EmbeddingConfig.from_environment(values)
39
+
40
+
41
+ def test_rejects_invalid_readiness_identity_and_ttl() -> None:
42
+ values = environment()
43
+ values["EMBEDDING_DEPLOYMENT_IDENTITY"] = "embedding-server"
44
+ with pytest.raises(ValueError, match="EMBEDDING_DEPLOYMENT_IDENTITY"):
45
+ EmbeddingConfig.from_environment(values)
46
+
47
+ values = environment()
48
+ values["EMBEDDING_READINESS_TTL_SECONDS"] = "301"
49
+ values["EMBEDDING_RUN_ENCRYPTION_SECRET"] = "x" * 32
50
+ with pytest.raises(ValueError, match="不能超过 300"):
51
+ EmbeddingConfig.from_environment(values)