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