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
|
@@ -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)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from southwind_embedding_server.config import EmbeddingConfig
|
|
9
|
+
from southwind_embedding_server.contracts import EmbeddingRequest
|
|
10
|
+
from southwind_embedding_server.pin_cli import compute_pins
|
|
11
|
+
from southwind_embedding_server.readiness import EmbeddingReadinessProbe
|
|
12
|
+
from southwind_embedding_server.readiness_contracts import (
|
|
13
|
+
AdapterEmbeddingReadinessV1,
|
|
14
|
+
compute_offers_digest,
|
|
15
|
+
compute_release_digest,
|
|
16
|
+
final_offers,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
IDENTITY = "windy-deployment-v1_0f26eb34-f384-4ec9-98f3-caf8d582291f"
|
|
20
|
+
ADAPTER_IDENTITY = "windy-deployment-v1_11c93162-e5f0-40af-93ce-9771a4bf12ed"
|
|
21
|
+
DIGEST_A = f"sha256:{'a' * 64}"
|
|
22
|
+
DIGEST_B = f"sha256:{'b' * 64}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def adapter_payload() -> dict[str, object]:
|
|
26
|
+
return {
|
|
27
|
+
"contractVersion": 1,
|
|
28
|
+
"status": "ready",
|
|
29
|
+
"runtimeKind": "embedding-adapter",
|
|
30
|
+
"deployment": {
|
|
31
|
+
"identity": ADAPTER_IDENTITY,
|
|
32
|
+
"releaseDigest": DIGEST_A,
|
|
33
|
+
"model": "embedding-model",
|
|
34
|
+
"version": "2026-08",
|
|
35
|
+
"dimensions": 3,
|
|
36
|
+
"normalized": True,
|
|
37
|
+
},
|
|
38
|
+
"offers": [
|
|
39
|
+
{
|
|
40
|
+
"variant": "text",
|
|
41
|
+
"capabilityVersion": "embedding.text/v1",
|
|
42
|
+
"capabilities": ["embedding.text"],
|
|
43
|
+
"inputModalities": ["text"],
|
|
44
|
+
"outputModalities": ["embedding"],
|
|
45
|
+
"limits": limits(),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"variant": "image",
|
|
49
|
+
"capabilityVersion": "embedding.image/v1",
|
|
50
|
+
"capabilities": ["embedding.image"],
|
|
51
|
+
"inputModalities": ["image"],
|
|
52
|
+
"outputModalities": ["embedding"],
|
|
53
|
+
"limits": {
|
|
54
|
+
**limits(),
|
|
55
|
+
"maxArtifacts": 8,
|
|
56
|
+
"maxPixelsPerArtifact": 20_000_000,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def limits() -> dict[str, int]:
|
|
64
|
+
return {
|
|
65
|
+
"maxDurationSeconds": 120,
|
|
66
|
+
"maxInputBytes": 64 * 1024 * 1024,
|
|
67
|
+
"maxOutputBytes": 64 * 1024 * 1024,
|
|
68
|
+
"maxUnits": 64,
|
|
69
|
+
"maxConcurrentRuns": 2,
|
|
70
|
+
"maxQueueDepth": 0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def base_config() -> EmbeddingConfig:
|
|
75
|
+
return EmbeddingConfig(
|
|
76
|
+
internal_token="internal",
|
|
77
|
+
artifact_url="http://server.internal/api/internal/ai/artifacts",
|
|
78
|
+
run_artifact_url="http://server.internal/api/internal/embeddings/runs",
|
|
79
|
+
adapter_url="http://adapter.internal",
|
|
80
|
+
adapter_token="adapter-token",
|
|
81
|
+
deployment_key="embedding.primary",
|
|
82
|
+
deployment_identity=IDENTITY,
|
|
83
|
+
release_digest=DIGEST_A,
|
|
84
|
+
offers_digest=DIGEST_B,
|
|
85
|
+
deployment_alias="intranet-v1",
|
|
86
|
+
model="embedding-model",
|
|
87
|
+
version="2026-08",
|
|
88
|
+
dimensions=3,
|
|
89
|
+
normalized=True,
|
|
90
|
+
capabilities=("embedding.text", "embedding.image"),
|
|
91
|
+
max_concurrent_runs=1,
|
|
92
|
+
max_queue_depth=0,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pinned_config() -> EmbeddingConfig:
|
|
97
|
+
config = base_config()
|
|
98
|
+
adapter = AdapterEmbeddingReadinessV1.model_validate(adapter_payload())
|
|
99
|
+
return replace(
|
|
100
|
+
config,
|
|
101
|
+
release_digest=compute_release_digest(adapter),
|
|
102
|
+
offers_digest=compute_offers_digest(final_offers(config, adapter)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def transport(status: int = 200) -> httpx.MockTransport:
|
|
107
|
+
return httpx.MockTransport(
|
|
108
|
+
lambda _request: httpx.Response(status, json=adapter_payload())
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def probe(config: EmbeddingConfig, status: int = 200) -> EmbeddingReadinessProbe:
|
|
113
|
+
return EmbeddingReadinessProbe(
|
|
114
|
+
config,
|
|
115
|
+
transport(status),
|
|
116
|
+
wall_clock=lambda: datetime(2026, 8, 6, 1, 2, 3, 4000, tzinfo=UTC),
|
|
117
|
+
monotonic_clock=lambda: 10.0,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_readiness_attests_bounded_atomic_offers_and_pins() -> None:
|
|
122
|
+
config = pinned_config()
|
|
123
|
+
inspection = await probe(config).inspect()
|
|
124
|
+
|
|
125
|
+
assert inspection.ready
|
|
126
|
+
assert inspection.snapshot["runtimeKind"] == "embedding"
|
|
127
|
+
assert [offer["variant"] for offer in inspection.offers] == ["text", "image"]
|
|
128
|
+
deployment = inspection.snapshot["deployment"]
|
|
129
|
+
assert isinstance(deployment, dict)
|
|
130
|
+
assert deployment == {
|
|
131
|
+
"key": config.deployment_key,
|
|
132
|
+
"identity": config.deployment_identity,
|
|
133
|
+
"releaseDigest": config.release_digest,
|
|
134
|
+
"offersDigest": config.offers_digest,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_unconfigured_or_failed_downstream_never_becomes_ready() -> None:
|
|
139
|
+
missing = await probe(replace(base_config(), adapter_url=None)).inspect()
|
|
140
|
+
assert not missing.ready
|
|
141
|
+
assert missing.snapshot["configured"] is False
|
|
142
|
+
|
|
143
|
+
failed = await probe(pinned_config(), 503).inspect()
|
|
144
|
+
assert not failed.ready
|
|
145
|
+
downstream = failed.snapshot["downstreamProbe"]
|
|
146
|
+
assert isinstance(downstream, dict)
|
|
147
|
+
assert downstream["failureCode"] == "RESOURCE_UNAVAILABLE"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_request_requires_exact_deployment_pin_and_matching_offer() -> None:
|
|
151
|
+
readiness = probe(pinned_config())
|
|
152
|
+
inspection = await readiness.inspect()
|
|
153
|
+
request = embedding_request(inspection.snapshot["deployment"])
|
|
154
|
+
|
|
155
|
+
assert readiness.supports(inspection, request)
|
|
156
|
+
assert request.readiness_pin is not None
|
|
157
|
+
drifted = request.model_copy(
|
|
158
|
+
update={
|
|
159
|
+
"readiness_pin": request.readiness_pin.model_copy(
|
|
160
|
+
update={"offers_digest": DIGEST_A}
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
assert not readiness.supports(inspection, drifted)
|
|
165
|
+
assert readiness.supports(
|
|
166
|
+
inspection, request.model_copy(update={"readiness_pin": None})
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_pin_cli_uses_the_same_real_probe_facts() -> None:
|
|
171
|
+
config = base_config()
|
|
172
|
+
pins = await compute_pins(config, transport())
|
|
173
|
+
expected = pinned_config()
|
|
174
|
+
assert pins == {
|
|
175
|
+
"deploymentKey": config.deployment_key,
|
|
176
|
+
"deploymentIdentity": config.deployment_identity,
|
|
177
|
+
"releaseDigest": expected.release_digest,
|
|
178
|
+
"offersDigest": expected.offers_digest,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def embedding_request(deployment: object) -> EmbeddingRequest:
|
|
183
|
+
assert isinstance(deployment, dict)
|
|
184
|
+
return EmbeddingRequest.model_validate(
|
|
185
|
+
{
|
|
186
|
+
"kind": "embedding",
|
|
187
|
+
"operationKey": "analysis.embedding.text",
|
|
188
|
+
"items": [{"id": "one", "text": "安全文本"}],
|
|
189
|
+
"options": {},
|
|
190
|
+
"inputModalities": ["text"],
|
|
191
|
+
"requiredCapabilities": ["embedding.text"],
|
|
192
|
+
"dataClassification": "sensitive",
|
|
193
|
+
"limits": {
|
|
194
|
+
"maxDurationSeconds": 30,
|
|
195
|
+
"maxInputBytes": 1024,
|
|
196
|
+
"maxOutputBytes": 1024,
|
|
197
|
+
"maxUnits": 10,
|
|
198
|
+
},
|
|
199
|
+
"providerEgress": {"mode": "forbidden"},
|
|
200
|
+
"readinessPin": deployment,
|
|
201
|
+
}
|
|
202
|
+
)
|