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.
- package/dist/cli.js +142 -37
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/_bunfig.toml +5 -0
- 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/Dockerfile +10 -5
- 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/apps/web/Dockerfile +1 -1
- 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/package.json +2 -0
- 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
|
@@ -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=
|
|
57
|
-
|
|
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
|
|
124
|
-
|
|
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))
|