create-windy 0.6.0 → 0.6.2

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 (61) hide show
  1. package/dist/cli.js +142 -37
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/_bunfig.toml +5 -0
  5. package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
  6. package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
  7. package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
  8. package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
  9. package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
  10. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
  11. package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
  12. package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
  13. package/template/apps/embedding-server/tests/test_config.py +13 -0
  14. package/template/apps/embedding-server/tests/test_readiness.py +202 -0
  15. package/template/apps/embedding-server/tests/test_run_http.py +76 -4
  16. package/template/apps/embedding-server/tests/test_run_service.py +3 -1
  17. package/template/apps/server/Dockerfile +10 -5
  18. package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
  19. package/template/apps/server/src/agent/run-facade.ts +2 -1
  20. package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
  22. package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
  23. package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
  24. package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
  25. package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
  26. package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
  27. package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
  28. package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
  29. package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
  30. package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
  31. package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
  32. package/template/apps/server/src/embedding/contracts.ts +6 -1
  33. package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
  34. package/template/apps/server/src/embedding/facade.ts +8 -6
  35. package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
  36. package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
  37. package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
  38. package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
  39. package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
  40. package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
  41. package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
  42. package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
  43. package/template/apps/web/Dockerfile +1 -1
  44. package/template/docker-compose.yml +11 -0
  45. package/template/docs/architecture/ai-runtime-readiness.md +6 -0
  46. package/template/docs/platform/vision-ai-runtime.md +22 -1
  47. package/template/package.json +2 -0
  48. package/template/packages/config/package.json +1 -1
  49. package/template/packages/database/package.json +1 -1
  50. package/template/packages/jobs/package.json +1 -1
  51. package/template/packages/modules/index.ts +1 -0
  52. package/template/packages/modules/package.json +1 -1
  53. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  54. package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
  55. package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
  56. package/template/packages/modules/src/manifest.ts +8 -0
  57. package/template/packages/server-sdk/package.json +1 -1
  58. package/template/packages/shared/index.ts +3 -0
  59. package/template/packages/shared/package.json +1 -1
  60. package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
  61. package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
@@ -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())
@@ -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))