create-windy 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +82 -2
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/embedding-server/src/southwind_embedding_server/app.py +27 -20
- package/template/apps/embedding-server/src/southwind_embedding_server/config.py +111 -4
- package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +13 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/pin_cli.py +62 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness.py +259 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_contracts.py +221 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/readiness_http.py +91 -0
- package/template/apps/embedding-server/src/southwind_embedding_server/run_service.py +23 -3
- package/template/apps/embedding-server/tests/test_config.py +13 -0
- package/template/apps/embedding-server/tests/test_readiness.py +202 -0
- package/template/apps/embedding-server/tests/test_run_http.py +76 -4
- package/template/apps/embedding-server/tests/test_run_service.py +3 -1
- package/template/apps/server/src/agent/drizzle-screenshot-intake-repository.integration.test.ts +3 -0
- package/template/apps/server/src/agent/run-facade.ts +2 -1
- package/template/apps/server/src/agent/run-route-screenshot-context.test.ts +13 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +2 -0
- package/template/apps/server/src/agent/screenshot-context-admission.test.ts +249 -0
- package/template/apps/server/src/agent/screenshot-context-attestation.ts +60 -0
- package/template/apps/server/src/agent/screenshot-intake-bootstrap.ts +2 -4
- package/template/apps/server/src/agent/screenshot-intake-test-support.ts +4 -0
- package/template/apps/server/src/agent/screenshot-intake-v2-validation.ts +24 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.test.ts +7 -0
- package/template/apps/server/src/agent/screenshot-intake-v2.ts +8 -10
- package/template/apps/server/src/agent/screenshot-run-binding.ts +17 -2
- package/template/apps/server/src/agent/vision-readiness-pin.ts +6 -48
- package/template/apps/server/src/ai-runtime-readiness/deployment-pin.ts +69 -0
- package/template/apps/server/src/embedding/contracts.ts +6 -1
- package/template/apps/server/src/embedding/facade-readiness.test.ts +307 -0
- package/template/apps/server/src/embedding/facade.ts +8 -6
- package/template/apps/server/src/embedding/readiness-admission.ts +26 -0
- package/template/apps/server/src/embedding/remote-runtime.test.ts +85 -0
- package/template/apps/server/src/embedding/remote-runtime.ts +21 -0
- package/template/apps/server/src/embedding/run-fingerprint.test.ts +26 -0
- package/template/apps/server/src/embedding/run-fingerprint.ts +11 -2
- package/template/apps/server/src/module-host/ai-operation-port.test.ts +72 -0
- package/template/apps/server/src/module-host/ai-operation-port.ts +43 -0
- package/template/apps/server/src/module-host/ai-operation-speech-normalize.test.ts +186 -0
- package/template/docker-compose.yml +11 -0
- package/template/docs/architecture/ai-runtime-readiness.md +6 -0
- package/template/docs/platform/vision-ai-runtime.md +22 -1
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/ai-screenshot-context.test.ts +72 -0
- package/template/packages/modules/src/ai-screenshot-context.ts +50 -0
- package/template/packages/modules/src/manifest.ts +8 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +3 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/screenshot-context-digest.test.ts +46 -0
- package/template/packages/shared/src/screenshot-context-digest.ts +72 -0
|
@@ -0,0 +1,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:
|
|
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)
|