sequence-base 0.3.0__py3-none-any.whl
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.
- sequence_base/__init__.py +134 -0
- sequence_base/auth.py +35 -0
- sequence_base/codec/__init__.py +25 -0
- sequence_base/codec/image.py +136 -0
- sequence_base/codec/resize.py +147 -0
- sequence_base/constants/__init__.py +0 -0
- sequence_base/constants/env.py +14 -0
- sequence_base/constants/headers.py +5 -0
- sequence_base/constants/version.py +9 -0
- sequence_base/contract/__init__.py +111 -0
- sequence_base/contract/accounts.py +93 -0
- sequence_base/contract/act.py +89 -0
- sequence_base/contract/benchmarks.py +130 -0
- sequence_base/contract/connect.py +53 -0
- sequence_base/contract/deployments.py +197 -0
- sequence_base/contract/errors.py +45 -0
- sequence_base/contract/eval.py +201 -0
- sequence_base/contract/flags.py +92 -0
- sequence_base/contract/policy.py +172 -0
- sequence_base/errors/__init__.py +0 -0
- sequence_base/errors/transport.py +36 -0
- sequence_base/obs/__init__.py +0 -0
- sequence_base/obs/log.py +57 -0
- sequence_base/obs/rid.py +31 -0
- sequence_base/schema_export.py +238 -0
- sequence_base/schemas/ActionChunk.json +102 -0
- sequence_base/schemas/ActionContract.json +43 -0
- sequence_base/schemas/ActionStep.json +23 -0
- sequence_base/schemas/AdapterTransform.json +42 -0
- sequence_base/schemas/Autoscaler.json +43 -0
- sequence_base/schemas/BenchmarkRegisterRequest.json +302 -0
- sequence_base/schemas/BenchmarkSpec.json +401 -0
- sequence_base/schemas/BootstrapResponse.json +76 -0
- sequence_base/schemas/ClampInfo.json +27 -0
- sequence_base/schemas/CodeBundle.json +27 -0
- sequence_base/schemas/Concurrency.json +21 -0
- sequence_base/schemas/Conditions.json +59 -0
- sequence_base/schemas/ConnectRequest.json +16 -0
- sequence_base/schemas/ConnectResponse.json +81 -0
- sequence_base/schemas/DeploymentAccepted.json +22 -0
- sequence_base/schemas/DeploymentPolicy.json +101 -0
- sequence_base/schemas/DeploymentRequest.json +467 -0
- sequence_base/schemas/DeploymentStatusResponse.json +45 -0
- sequence_base/schemas/ErrorBody.json +32 -0
- sequence_base/schemas/Estimate.json +25 -0
- sequence_base/schemas/EvalDryRunResponse.json +41 -0
- sequence_base/schemas/EvalReport.json +179 -0
- sequence_base/schemas/EvalRequest.json +111 -0
- sequence_base/schemas/EvalResponse.json +88 -0
- sequence_base/schemas/ImageFrame.json +36 -0
- sequence_base/schemas/ImageSpec.json +123 -0
- sequence_base/schemas/ImageStepApt.json +18 -0
- sequence_base/schemas/ImageStepEnv.json +18 -0
- sequence_base/schemas/ImageStepRun.json +18 -0
- sequence_base/schemas/ImageStepUvPip.json +18 -0
- sequence_base/schemas/Lease.json +28 -0
- sequence_base/schemas/LossyTransform.json +21 -0
- sequence_base/schemas/MetricAgg.json +87 -0
- sequence_base/schemas/Observation.json +133 -0
- sequence_base/schemas/PolicyRegistration.json +438 -0
- sequence_base/schemas/PolicySpec.json +346 -0
- sequence_base/schemas/Proprioception.json +67 -0
- sequence_base/schemas/RenderSpec.json +22 -0
- sequence_base/schemas/Resources.json +29 -0
- sequence_base/schemas/RolloutResult.json +59 -0
- sequence_base/schemas/SecretCreateRequest.json +25 -0
- sequence_base/schemas/SecretInfo.json +44 -0
- sequence_base/schemas/SecretRef.json +16 -0
- sequence_base/schemas/SeqFlags.json +33 -0
- sequence_base/schemas/Step.json +68 -0
- sequence_base/schemas/SuiteAgg.json +26 -0
- sequence_base/schemas/TaskAgg.json +26 -0
- sequence_base/schemas/Timeouts.json +20 -0
- sequence_base/schemas/UsageReport.json +38 -0
- sequence_base/schemas/Variant.json +24 -0
- sequence_base/schemas/VolumeAttach.json +26 -0
- sequence_base/schemas/VolumeMount.json +21 -0
- sequence_base/schemas/VolumeRef.json +28 -0
- sequence_base/schemas/WarmingError.json +50 -0
- sequence_base/telemetry/__init__.py +13 -0
- sequence_base/telemetry/events.py +101 -0
- sequence_base-0.3.0.dist-info/METADATA +12 -0
- sequence_base-0.3.0.dist-info/RECORD +84 -0
- sequence_base-0.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
契约 6 — accounts / secret / billing wire types (full picture in docs/platform/accounts.md).
|
|
3
|
+
|
|
4
|
+
Secret-leak rule, mechanically checkable (goal 15). Two kinds of secret:
|
|
5
|
+
(a) platform-minted — `direct_secret`, TLS private key. May appear in the DOWN direction only:
|
|
6
|
+
ConnectResponse.direct_secret, BootstrapResponse.direct_secret + tls_key.
|
|
7
|
+
(b) user Secret-Manager values. May appear in the WRITE direction only:
|
|
8
|
+
SecretCreateRequest.env, BootstrapResponse.env (injected into the worker subprocess).
|
|
9
|
+
These four fields are the entire allow-list for carrying a plaintext secret value. Everything returned
|
|
10
|
+
to a client, reported asynchronously, or written to a report/log (SecretRef, SecretInfo, UsageReport,
|
|
11
|
+
eval report, /act response) exposes only names / env_keys — never a value-bearing field.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
from sequence_base.contract.connect import Lease
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SecretRef",
|
|
23
|
+
"SecretCreateRequest",
|
|
24
|
+
"SecretInfo",
|
|
25
|
+
"BootstrapResponse",
|
|
26
|
+
"UsageReport",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SecretRef(BaseModel):
|
|
31
|
+
"""A by-name reference to a secret (PolicySpec.secrets / BenchmarkSpec.secrets). Name only — the
|
|
32
|
+
value lives in Secret Manager and is injected at worker bootstrap. Never carries a value."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SecretCreateRequest(BaseModel):
|
|
40
|
+
"""POST /v1/secrets — one name = one group of env values (e.g. openrouter = {OPENROUTER_API_KEY}).
|
|
41
|
+
`env` carries plaintext values; this is the WRITE-direction allow-list field (goal 15)."""
|
|
42
|
+
|
|
43
|
+
# F6: write-direction plaintext carrier — hide_input_in_errors so a malformed body's ValidationError
|
|
44
|
+
# str() never echoes the raw secret value (symmetric with SecretInfo). RUNTIME config, no schema change.
|
|
45
|
+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
env: dict[str, str] = Field(..., description="ENV_KEY → value; write-only, never echoed back")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SecretInfo(BaseModel):
|
|
52
|
+
"""GET /v1/secrets element — metadata only, NO values. Exposes `env_keys`, not `env`."""
|
|
53
|
+
|
|
54
|
+
# hide_input_in_errors: if a malformed wire response carries a value-bearing extra field, extra="forbid"
|
|
55
|
+
# raises ValidationError; without this, its str() would echo the raw input (the secret value). Defense in
|
|
56
|
+
# depth so no caller that forgets to guard model_validate can leak it. RUNTIME config, not a wire shape —
|
|
57
|
+
# does NOT change the serialized schema, so CONTRACT_VERSION is unaffected.
|
|
58
|
+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
env_keys: list[str] = Field(..., description="The env var names in this secret — values withheld")
|
|
62
|
+
created_at: datetime
|
|
63
|
+
last_used_at: datetime | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BootstrapResponse(BaseModel):
|
|
67
|
+
"""POST /v1/internal/bootstrap (worker instance-identity JWT) — everything a worker needs to serve
|
|
68
|
+
direct TLS `/act`. `direct_secret` + `tls_key` are DOWN-direction platform secrets; `env` is the
|
|
69
|
+
WRITE-direction injection of the policy's declared user secrets into its subprocess (goal 15)."""
|
|
70
|
+
|
|
71
|
+
# F6: carries down-direction platform secrets + write-direction user env — hide_input_in_errors so a
|
|
72
|
+
# malformed body's ValidationError never echoes plaintext (symmetric with SecretInfo). RUNTIME config.
|
|
73
|
+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
|
|
74
|
+
|
|
75
|
+
direct_secret: str = Field(..., description="Platform-minted bearer the worker's guard checks")
|
|
76
|
+
tls_key: str = Field(..., description="Self-signed TLS private key (down-direction platform secret)")
|
|
77
|
+
tls_cert: str = Field(..., description="Self-signed TLS certificate (public)")
|
|
78
|
+
env: dict[str, str] = Field(..., description="Policy's user secrets, expanded ENV_KEY → value")
|
|
79
|
+
weights_uri: str
|
|
80
|
+
lease: Lease
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class UsageReport(BaseModel):
|
|
84
|
+
"""POST /v1/internal/usage — the worker's async metering report. Carries counts only, no secrets.
|
|
85
|
+
This usage shape is defined here (sequence-base) once, so gateway and worker cannot drift."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
deployment_id: str
|
|
90
|
+
window_start: datetime
|
|
91
|
+
window_end: datetime
|
|
92
|
+
requests: int
|
|
93
|
+
controlled_seconds: float
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared /act contract — the obs/action shapes used by the robot SDK, the gateway, and the
|
|
3
|
+
container harness. One definition, three consumers, so the wire format cannot drift.
|
|
4
|
+
|
|
5
|
+
Extracted verbatim (fields) from general-sequences/backend/sequences/api/schema.py. Only the
|
|
6
|
+
core DATA types live here; request/response/perf envelopes (ActRequest, ActResponse, ActPerf …)
|
|
7
|
+
stay in the gateway, which is their only consumer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImageFrame(BaseModel):
|
|
17
|
+
"""One camera frame at one instant. Repeat a `view` (ordered by timestamp_ms) for a series."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
view: str = Field(..., description="Logical view name, e.g. top / wrist_left / exterior_1. "
|
|
22
|
+
"May appear more than once to express a time series")
|
|
23
|
+
# URLs are NOT fetched — the string is passed to the worker untouched (an SSRF guard would be
|
|
24
|
+
# needed before ever dereferencing one). {schema.py:76}
|
|
25
|
+
data: str = Field(..., description="Base64-encoded image (PNG or JPEG). URLs are not fetched.")
|
|
26
|
+
timestamp_ms: int | None = Field(default=None,
|
|
27
|
+
description="Orders multiple frames of a view; array order used when omitted")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Proprioception(BaseModel):
|
|
31
|
+
"""Proprioceptive state. Only joint_positions is required; the rest is zero-padded per model."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
joint_positions: list[float] = Field(..., description="Joint angles, in radians")
|
|
36
|
+
joint_velocities: list[float] | None = None
|
|
37
|
+
# [x,y,z, qx,qy,qz,qw]; the quaternion avoids gimbal lock from Euler angles.
|
|
38
|
+
end_effector_pose: list[float] | None = Field(default=None, min_length=7, max_length=7)
|
|
39
|
+
# A list, not a scalar: a dual-arm setup has two grippers.
|
|
40
|
+
gripper: list[float] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Observation(BaseModel):
|
|
44
|
+
"""The robot's world at one instant: camera frames + proprioception + a language instruction."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
# max_length bounds the frame COUNT (not bytes; body size is capped by the gateway middleware).
|
|
49
|
+
# 256 clears the widest catalogue window while refusing a memory-exhaustion list. {schema.py:97}
|
|
50
|
+
images: list[ImageFrame] = Field(..., min_length=1, max_length=256)
|
|
51
|
+
proprioception: Proprioception
|
|
52
|
+
instruction: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ActionSpace(str, Enum):
|
|
56
|
+
"""Must be explicit: the same floats mean opposite things across spaces — executing an absolute
|
|
57
|
+
joint position as a delta flings the arm into its limit stops. {schema.py:124}"""
|
|
58
|
+
|
|
59
|
+
JOINT_ABSOLUTE = "joint_absolute"
|
|
60
|
+
JOINT_DELTA = "joint_delta"
|
|
61
|
+
EE_ABSOLUTE = "ee_absolute"
|
|
62
|
+
EE_DELTA = "ee_delta"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ActionStep(BaseModel):
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
index: int
|
|
69
|
+
values: list[float]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ActionChunk(BaseModel):
|
|
73
|
+
"""A chunk of actions. `covers_seconds` is what the robot schedules the next request against —
|
|
74
|
+
it must ask again before this much motion finishes playing out."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(extra="forbid")
|
|
77
|
+
|
|
78
|
+
steps: list[ActionStep]
|
|
79
|
+
# Echoed back, not left to the caller: the same floats mean different things per body, and the
|
|
80
|
+
# caller may not be the code that chose the model.
|
|
81
|
+
robot: str
|
|
82
|
+
action_space: ActionSpace
|
|
83
|
+
action_dim: int
|
|
84
|
+
control_frequency_hz: float
|
|
85
|
+
covers_seconds: float
|
|
86
|
+
# How many steps to execute before asking again; None → execute the whole chunk.
|
|
87
|
+
open_loop_horizon: int | None = None
|
|
88
|
+
# How long the executed steps (open_loop_horizon of them) keep the arm moving.
|
|
89
|
+
sustains_seconds: float | None = None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
契约 5 (registration half) — benchmark is a second authorizable artifact, its registration symmetric to
|
|
3
|
+
a policy's. `BenchmarkSpec` carries the same infra declaration a PolicySpec does, plus a structured
|
|
4
|
+
`Conditions` (the eval matrix), versioned `assets` volumes, an optional `render` (mp4) declaration, and
|
|
5
|
+
SeqFlags→method bindings covering SETUP / RESET / CHECK / SCORE. The eval *run* wire types (RolloutResult,
|
|
6
|
+
EvalReport, …) live in contract/eval.py.
|
|
7
|
+
|
|
8
|
+
eval-raw revision: the platform provides the reset→step→check loop as a tool; the user hooks the goal in.
|
|
9
|
+
`Step` is what `@seq.check` returns each step (platform never invents a success metric — the user names
|
|
10
|
+
them); `RenderSpec` (a.k.a. `seq.Record`) declares the mp4 the platform records. Note `Step` here is the
|
|
11
|
+
benchmark per-step result — a different type from ImageSpec's build `ImageStep*` variants.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
from sequence_base.contract.accounts import SecretRef
|
|
18
|
+
from sequence_base.contract.flags import BENCHMARK_BINDABLE, SeqFlags
|
|
19
|
+
from sequence_base.contract.policy import ImageSpec, VolumeRef
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Variant",
|
|
23
|
+
"Conditions",
|
|
24
|
+
"RenderSpec",
|
|
25
|
+
"Step",
|
|
26
|
+
"BenchmarkSpec",
|
|
27
|
+
"BenchmarkRegisterRequest",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Variant(BaseModel):
|
|
32
|
+
"""One structured variant axis of the eval matrix (e.g. axis="lighting", values=["day","night"])."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
axis: str
|
|
37
|
+
values: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Conditions(BaseModel):
|
|
41
|
+
"""The eval matrix — structured, not a bare dict. `tasks` and `seeds` required; `variants` optional."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
tasks: list[str]
|
|
46
|
+
seeds: list[int]
|
|
47
|
+
variants: list[Variant] = Field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RenderSpec(BaseModel):
|
|
51
|
+
"""a.k.a. `seq.Record` — declares the mp4 the platform records (money-shot is a platform tool, not user
|
|
52
|
+
glue). `camera` picks which camera stream to pull frames from; `fps` is the output frame rate. Used two
|
|
53
|
+
ways: as `BenchmarkSpec.render` (record the whole eval) and as `Step.record` (a per-frame hint)."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
camera: str = Field(..., description="Which camera to pull frames from, e.g. 'wrist' / 'agentview'")
|
|
58
|
+
fps: int = 30
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Step(BaseModel):
|
|
62
|
+
"""What `@seq.check` returns each step. The platform settles each env per-step from this and NEVER
|
|
63
|
+
invents a success metric — `done` is the user's call, and `metrics` keys are user-named (the platform
|
|
64
|
+
neither enumerates nor validates the names; each value must be a float). A per-frame `record` hint may
|
|
65
|
+
ask the platform to record this frame into the mp4."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
done: bool
|
|
70
|
+
terminated: bool = False
|
|
71
|
+
truncated: bool = False
|
|
72
|
+
metrics: dict[str, float] = Field(
|
|
73
|
+
default_factory=dict, description="User-named metrics; platform does not know the field names"
|
|
74
|
+
)
|
|
75
|
+
record: RenderSpec | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BenchmarkSpec(BaseModel):
|
|
79
|
+
"""What `@seq.benchmark(...)` accrues — symmetric to PolicySpec. `secrets` are by-name only (accounts
|
|
80
|
+
⑦: a benchmark also references secrets by name). `assets` are versioned scene/mesh/URDF/MJCF/USD volumes
|
|
81
|
+
the worker mounts at /assets (reusing S2's Volume mechanism). `render` optionally declares the mp4.
|
|
82
|
+
`max_steps` is the platform SAFETY cap (cost backstop, records `truncated` — NOT semantic termination;
|
|
83
|
+
`done` is decided by @seq.check). `bindings` covers SETUP / RESET / CHECK / SCORE (ROLLOUT is deprecated
|
|
84
|
+
and must not appear)."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(extra="forbid")
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
image: ImageSpec
|
|
90
|
+
conditions: Conditions
|
|
91
|
+
max_steps: int = Field(..., description="Platform safety cap; truncates and records truncated")
|
|
92
|
+
handler_ref: str
|
|
93
|
+
bindings: dict[SeqFlags, str] = Field(..., description="SETUP / RESET / CHECK / SCORE → method name")
|
|
94
|
+
gpu: str | list[str] | None = None
|
|
95
|
+
assets: list[VolumeRef] = Field(default_factory=list)
|
|
96
|
+
render: RenderSpec | None = None
|
|
97
|
+
cpu: int | None = None
|
|
98
|
+
memory_gb: int | None = None
|
|
99
|
+
secrets: list[SecretRef] = Field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="after")
|
|
102
|
+
def _bindings_only_benchmark_hooks(self) -> "BenchmarkSpec":
|
|
103
|
+
# eval-raw: only SETUP / RESET / CHECK / SCORE are bindable. ROLLOUT is deprecated (never bound) and
|
|
104
|
+
# a policy-lifecycle flag has no place here — either is a malformed registration, caught at build.
|
|
105
|
+
bad = set(self.bindings) - set(BENCHMARK_BINDABLE)
|
|
106
|
+
if bad:
|
|
107
|
+
names = ", ".join(sorted(f.name or str(f.value) for f in bad))
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"benchmark bindings may only cover SETUP / RESET / CHECK / SCORE; got disallowed {names}"
|
|
110
|
+
" (ROLLOUT is deprecated and must not be bound)"
|
|
111
|
+
)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BenchmarkRegisterRequest(BaseModel):
|
|
116
|
+
"""POST /v1/benchmarks — register body, same types as the spec (ImageSpec / Conditions / VolumeRef /
|
|
117
|
+
RenderSpec). Carries assets / render / cpu / memory_gb / max_steps so the gateway can build the image,
|
|
118
|
+
provision the `assets` Volume, and register in one call."""
|
|
119
|
+
|
|
120
|
+
model_config = ConfigDict(extra="forbid")
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
image: ImageSpec
|
|
124
|
+
conditions: Conditions
|
|
125
|
+
max_steps: int
|
|
126
|
+
handler_ref: str
|
|
127
|
+
assets: list[VolumeRef] = Field(default_factory=list)
|
|
128
|
+
render: RenderSpec | None = None
|
|
129
|
+
cpu: int | None = None
|
|
130
|
+
memory_gb: int | None = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
契约 3 — connect / lease (client → gateway; control plane, first call + renewal).
|
|
3
|
+
|
|
4
|
+
Control plane / data plane split: `connect(model)` goes through the gateway once (admission → acquire →
|
|
5
|
+
ensure worker up) and hands back the *direct* coordinates the client then uses to hit the worker's
|
|
6
|
+
`/act` without the gateway in the hot path (契约 4). The cold-start fallback is a defined wire body,
|
|
7
|
+
not an HTTP header alone — a 503 returns `WarmingError` (contract/errors.py), so `503 + Retry-After`
|
|
8
|
+
and the warming ETA are typed and checkable.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
# The 503 cold/warming body is defined once in contract.errors and reused here — no second shape.
|
|
17
|
+
from sequence_base.contract.errors import WarmingError
|
|
18
|
+
|
|
19
|
+
__all__ = ["Lease", "ConnectRequest", "ConnectResponse", "WarmingError"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Lease(BaseModel):
|
|
23
|
+
"""A time-boxed grant of the direct coordinates. Renewed before `expires_at`; `epoch` is the
|
|
24
|
+
worker/secret generation — a client holding a stale epoch against a new worker gets 401 and must
|
|
25
|
+
re-connect."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
ttl_s: int
|
|
30
|
+
expires_at: datetime
|
|
31
|
+
epoch: int = Field(..., description="Worker/secret generation counter; rolls on worker change")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConnectRequest(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
model: str = Field(..., description="Deployment name to connect to")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConnectResponse(BaseModel):
|
|
41
|
+
"""200 — the full set of coordinates for a direct worker connection.
|
|
42
|
+
|
|
43
|
+
`direct_secret` is a plaintext platform-minted bearer for the worker's `/act`; it is on the
|
|
44
|
+
allow-list of fields permitted to carry a secret value in the down direction (契约 6 / goal 15)."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
worker_url: str = Field(..., description="Worker static-IP direct URL, e.g. https://<ip>:8080")
|
|
49
|
+
cert_fingerprint: str = Field(..., description="Self-signed cert fingerprint the SDK pins")
|
|
50
|
+
direct_secret: str = Field(..., description="Bearer for the worker /act; valid within the lease")
|
|
51
|
+
lease: Lease
|
|
52
|
+
warming: bool = Field(default=False, description="True → worker still warming; see warming_eta_s")
|
|
53
|
+
warming_eta_s: int | None = Field(default=None, description="Seconds until warm, when warming")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
契约 2 — `POST /v1/deployments` (SDK `seq deploy` → gateway) and its responses.
|
|
3
|
+
|
|
4
|
+
The request is the normalized wire form of a PolicySpec: one big body (à la Modal's single `Function`
|
|
5
|
+
message — new config adds a field, never a new endpoint) whose sub-shapes reuse contract 1's types
|
|
6
|
+
(ImageSpec, VolumeMount, Concurrency, ActionContract). Field names align with the PolicySpec side —
|
|
7
|
+
`gpu` is normalized to list[str] here, the memory field is `memory_gb` (goal 7).
|
|
8
|
+
|
|
9
|
+
Responses have no open-ended fields: 202 → status fixed to `building`; GET → status is a restricted
|
|
10
|
+
enum building | ready | failed, with a failure channel (`error`) when failed (goal 8).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import hashlib
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
21
|
+
|
|
22
|
+
from sequence_base.contract.accounts import SecretRef
|
|
23
|
+
from sequence_base.contract.policy import (
|
|
24
|
+
ActionContract,
|
|
25
|
+
Concurrency,
|
|
26
|
+
ImageSpec,
|
|
27
|
+
VolumeMount,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MAX_CODE_BUNDLE_BYTES",
|
|
32
|
+
"CodeBundle",
|
|
33
|
+
"Resources",
|
|
34
|
+
"Autoscaler",
|
|
35
|
+
"Timeouts",
|
|
36
|
+
"VolumeAttach",
|
|
37
|
+
"DeploymentPolicy",
|
|
38
|
+
"DeploymentRequest",
|
|
39
|
+
"DeploymentAccepted",
|
|
40
|
+
"DeploymentState",
|
|
41
|
+
"DeploymentStatusResponse",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Resources(BaseModel):
|
|
46
|
+
"""= Modal Resources + GPUConfig. `gpu` is list[str] (the PolicySpec `str | list[str]` normalized)."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
gpu: list[str]
|
|
51
|
+
cpu: int
|
|
52
|
+
memory_gb: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Autoscaler(BaseModel):
|
|
56
|
+
"""= Modal AutoscalerSettings. Defaults mirror PolicySpec so an omitted block scale-to-zeroes."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
min_containers: int = 0
|
|
61
|
+
max_containers: int | None = None
|
|
62
|
+
buffer_containers: int | None = None
|
|
63
|
+
scaledown_window: int = 600
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Timeouts(BaseModel):
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
request: int
|
|
70
|
+
startup: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class VolumeAttach(BaseModel):
|
|
74
|
+
"""A non-weights volume mount in the deploy body (goal 7). Distinct from `weights` (a `VolumeMount`,
|
|
75
|
+
a provisioned pull at the reserved `/data`): an explicit `volumes=` attachment names a persistent
|
|
76
|
+
volume by `vol://<name>` ref, pins its container `mount`, and carries the `create_if_missing`
|
|
77
|
+
provisioning intent so the gateway either binds an existing volume or creates it — the semantic the
|
|
78
|
+
wire `VolumeMount` deliberately does not carry. Not reused/extended from `VolumeMount` because
|
|
79
|
+
`create_if_missing` is meaningless for the weights pull."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="forbid")
|
|
82
|
+
|
|
83
|
+
uri: str
|
|
84
|
+
mount: str
|
|
85
|
+
create_if_missing: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DeploymentPolicy(BaseModel):
|
|
89
|
+
"""The policy block of the deployments body. `handler_ref` and `action` are required; `action` is the
|
|
90
|
+
fixed obs→ActionChunk contract, not a bare dict.
|
|
91
|
+
|
|
92
|
+
`secrets` carries the policy's declared secret BUNDLES BY NAME ONLY (list[SecretRef]) — the gateway
|
|
93
|
+
validates each name exists and belongs to the deployer at submit time (accounts ⑦ / S3 goal 14), and the
|
|
94
|
+
worker bootstrap fetches the matching env group by name. A secret VALUE never travels here: SecretRef is
|
|
95
|
+
name-only, so this field cannot carry one (S3 goal 15). Defaults to `[]` so a policy with no secrets is
|
|
96
|
+
unaffected."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="forbid")
|
|
99
|
+
|
|
100
|
+
handler_ref: str
|
|
101
|
+
action: ActionContract
|
|
102
|
+
streaming: bool = False
|
|
103
|
+
frames_per_chunk: int | None = None
|
|
104
|
+
secrets: list[SecretRef] = Field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
MAX_CODE_BUNDLE_BYTES = 8 * 1024 * 1024
|
|
108
|
+
"""Upper bound on the compressed code bundle. Policy source is kilobytes; anything near this is weights or
|
|
109
|
+
data that belongs in `weights` / a volume, and the bound keeps the deploy body well under the gateway's
|
|
110
|
+
request limit (base64 adds a third)."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CodeBundle(BaseModel):
|
|
114
|
+
"""The policy's own source, shipped with the deploy so the worker can import `policy.handler_ref`
|
|
115
|
+
(what Modal's automount does for the entrypoint module). A gzip'd tar of the handler's top-level module
|
|
116
|
+
or package, base64 on the wire, with the sha256 of the decoded bytes so a truncated or altered upload
|
|
117
|
+
is refused at the edge instead of failing as an ImportError on a GPU."""
|
|
118
|
+
|
|
119
|
+
model_config = ConfigDict(extra="forbid")
|
|
120
|
+
|
|
121
|
+
format: Literal["tar.gz"] = "tar.gz"
|
|
122
|
+
data: str
|
|
123
|
+
sha256: str
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="after")
|
|
126
|
+
def _intact(self) -> "CodeBundle":
|
|
127
|
+
raw = self.raw()
|
|
128
|
+
if len(raw) > MAX_CODE_BUNDLE_BYTES:
|
|
129
|
+
raise ValueError(f"code bundle is {len(raw)} bytes; the limit is {MAX_CODE_BUNDLE_BYTES} "
|
|
130
|
+
"(ship weights/data via `weights` or a volume, not with the code)")
|
|
131
|
+
if hashlib.sha256(raw).hexdigest() != self.sha256:
|
|
132
|
+
raise ValueError("code bundle sha256 does not match its data")
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def raw(self) -> bytes:
|
|
136
|
+
try:
|
|
137
|
+
return base64.b64decode(self.data, validate=True)
|
|
138
|
+
except binascii.Error as exc:
|
|
139
|
+
raise ValueError(f"code bundle data is not valid base64: {exc}") from None
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_bytes(cls, raw: bytes) -> "CodeBundle":
|
|
143
|
+
return cls(data=base64.b64encode(raw).decode("ascii"), sha256=hashlib.sha256(raw).hexdigest())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DeploymentRequest(BaseModel):
|
|
147
|
+
"""The full `POST /v1/deployments` body. Required: name, image, weights, resources, policy (goal 18).
|
|
148
|
+
autoscaler / concurrency / timeouts are typed optional knobs with sane defaults."""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
name: str
|
|
153
|
+
image: ImageSpec
|
|
154
|
+
weights: VolumeMount
|
|
155
|
+
resources: Resources
|
|
156
|
+
policy: DeploymentPolicy
|
|
157
|
+
# The handler's source. None only when `handler_ref` is importable from the image itself (a package the
|
|
158
|
+
# ImageSpec installs); otherwise the worker has nothing to import and the build fails.
|
|
159
|
+
code: CodeBundle | None = None
|
|
160
|
+
# Non-weights volumes the policy declares (goal 7). List, not dict, so a mount is never duplicated as
|
|
161
|
+
# both key and value; the gateway only stores the dump, no consumer needs dict lookup.
|
|
162
|
+
volumes: list[VolumeAttach] = Field(default_factory=list)
|
|
163
|
+
autoscaler: Autoscaler = Field(default_factory=Autoscaler)
|
|
164
|
+
# An omitted @seq.concurrent must NOT send null: the gateway would then have no concurrency to scale by.
|
|
165
|
+
# Materialize the spec default — contracts.md §2's example {"max_inputs": 1, "target_inputs": 1}, i.e.
|
|
166
|
+
# one input per container (Modal's no-`@modal.concurrent` semantics) — same discipline as `autoscaler`
|
|
167
|
+
# (goal 13).
|
|
168
|
+
concurrency: Concurrency = Field(default_factory=lambda: Concurrency(max_inputs=1, target_inputs=1))
|
|
169
|
+
timeouts: Timeouts | None = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DeploymentAccepted(BaseModel):
|
|
173
|
+
"""202 — the build is async; the id comes back first, status pinned to `building`."""
|
|
174
|
+
|
|
175
|
+
model_config = ConfigDict(extra="forbid")
|
|
176
|
+
|
|
177
|
+
deployment_id: str
|
|
178
|
+
status: Literal["building"] = "building"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DeploymentState(str, Enum):
|
|
182
|
+
"""GET status — a restricted enum, not a free string (same discipline as EvalStatus)."""
|
|
183
|
+
|
|
184
|
+
BUILDING = "building"
|
|
185
|
+
READY = "ready"
|
|
186
|
+
FAILED = "failed"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class DeploymentStatusResponse(BaseModel):
|
|
190
|
+
"""GET /v1/deployments/{id}. `error` carries the build-failure reason when status is FAILED —
|
|
191
|
+
the failure channel the spec and the old goal both lacked (goal 8)."""
|
|
192
|
+
|
|
193
|
+
model_config = ConfigDict(extra="forbid")
|
|
194
|
+
|
|
195
|
+
status: DeploymentState
|
|
196
|
+
model: str
|
|
197
|
+
error: str | None = Field(default=None, description="Build-failure reason; set only when FAILED")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared error-body wire types (契约 4 / 契约 6 client semantics). These are the *bodies* returned to a
|
|
3
|
+
client on the sad path — distinct from `sequence_base.errors.transport`, which is the internal
|
|
4
|
+
exception taxonomy the gateway / harness raise. Defined here once so "is an error response a wire
|
|
5
|
+
type?" has one answer: yes, and this is its shape.
|
|
6
|
+
|
|
7
|
+
402 OutOfCredit → ErrorBody(code=OUT_OF_CREDIT) (connect / lease renew / eval balance)
|
|
8
|
+
401 Unauthorized → ErrorBody(code=UNAUTHORIZED) (secret expired / epoch rolled)
|
|
9
|
+
503 Warming → WarmingError(code=WARMING, ...) (worker cold / warming; carries retry hints)
|
|
10
|
+
|
|
11
|
+
`WarmingError` is the single definition of the 503 body: contract 3's connect cold-path (503 +
|
|
12
|
+
Retry-After / warming eta) reuses it rather than declaring a second {retry_after_s, eta_s} shape.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ErrorCode(str, Enum):
|
|
22
|
+
"""Restricted set — a client switches on this, so it is an enum, not a free string."""
|
|
23
|
+
|
|
24
|
+
OUT_OF_CREDIT = "out_of_credit"
|
|
25
|
+
UNAUTHORIZED = "unauthorized"
|
|
26
|
+
WARMING = "warming"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ErrorBody(BaseModel):
|
|
30
|
+
"""The base error body (402 / 401): a machine-readable `code` plus a human `message`."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
code: ErrorCode
|
|
35
|
+
message: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WarmingError(ErrorBody):
|
|
39
|
+
"""503 body — worker cold / warming. Adds the two retry hints so `503 + Retry-After` and the
|
|
40
|
+
warming ETA are expressible in the type, not smuggled in an HTTP header alone. `code` defaults to
|
|
41
|
+
WARMING; a caller waits `retry_after_s` and, when known, learns `eta_s` until hot."""
|
|
42
|
+
|
|
43
|
+
code: ErrorCode = ErrorCode.WARMING
|
|
44
|
+
retry_after_s: int
|
|
45
|
+
eta_s: int | None = Field(default=None, description="Seconds until warm, when estimable")
|