From f3a05ba287f4d5a96afe4d5f72be3abceacdd352 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Wed, 16 Sep 2026 16:55:43 +0200 Subject: [PATCH 1/3] fix(sdk): document terminal streaming execution evidence --- .../sie_gateway/docs/architecture-guide.md | 2 +- packages/sie_gateway/openapi.json | 73 +++++++----- packages/sie_gateway/src/handlers/proxy.rs | 112 +++++++++++++++++- packages/sie_gateway/src/openapi.rs | 33 +++++- packages/sie_sdk/README.md | 17 +++ packages/sie_sdk/src/sie_sdk/client/sync.py | 13 +- packages/sie_sdk/src/sie_sdk/types.py | 8 ++ packages/sie_sdk/tests/client/test_chat.py | 30 +++++ .../sie_sdk/tests/client/test_chat_async.py | 33 ++++++ packages/sie_ts_sdk/src/types.ts | 8 ++ packages/sie_ts_sdk/tests/streaming.test.ts | 31 +++++ 11 files changed, 323 insertions(+), 37 deletions(-) diff --git a/packages/sie_gateway/docs/architecture-guide.md b/packages/sie_gateway/docs/architecture-guide.md index 0dba034b9..c5e2383db 100644 --- a/packages/sie_gateway/docs/architecture-guide.md +++ b/packages/sie_gateway/docs/architecture-guide.md @@ -57,7 +57,7 @@ Rules enforced on the inference path: - Ordinary encode, score, and extract use pool-queue dispatch; capped logical batch pools and generation use worker direct-dispatch. Both paths use JetStream, with no direct-HTTP fallback. `src/handlers/proxy.rs` is the JetStream submission handler despite its name. - If the queue transport is unavailable (no usable NATS client at init), the gateway returns `503`. It does not fall back to direct mode. - Unknown model ids fast-fail with `404` whenever the in-memory `ModelRegistry` has been populated (either by the filesystem seed or by a successful bootstrap / delta from `sie-config`). In the pre-bootstrap edge case where the registry is still empty — no seed, no export applied yet — the proxy falls back to the caller-supplied bundle (or `"default"`) so an unseeded gateway can still publish work to a cold pool that a caller pinned via `X-SIE-Pool`. Once any model is registered, this fallback is disabled and the 404 contract applies. -- Automatic pool selection only considers healthy workers whose reported `bundle_config_hash` matches the gateway's expected hash. Explicit `X-SIE-Pool` selects a logical pool; the gateway publishes to that pool's backing queue pool (`PoolSpec.queue_pool`, default `default`) and stamps the logical pool into the work item as `admission_pool`. Deliveries whose hash is not the worker-sidecar's current hash, or logical pools that do not assign that worker, are NAKed before backend IPC. Successful non-streaming results echo the execution hash; the gateway emits `X-SIE-Model-Revision` only when every successful result matches the hash and immutable revision captured atomically at routing time. Streaming responses omit this post-execution attestation because headers precede the terminal result. +- Automatic pool selection only considers healthy workers whose reported `bundle_config_hash` matches the gateway's expected hash. Explicit `X-SIE-Pool` selects a logical pool; the gateway publishes to that pool's backing queue pool (`PoolSpec.queue_pool`, default `default`) and stamps the logical pool into the work item as `admission_pool`. Deliveries whose hash is not the worker-sidecar's current hash, or logical pools that do not assign that worker, are NAKed before backend IPC. Successful non-streaming results echo the execution hash; the gateway emits `X-SIE-Model-Revision` only when every successful result matches the hash and immutable revision captured atomically at routing time. The header value is the lowercase 64-hex executed bundle/config SHA-256, not the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered generation also follows this rule even though it internally collects worker chunks. True SSE responses (`stream: true`) always omit the header because headers precede the terminal result. Successful terminal SSE events may instead carry the worker-origin `execution_identity_sha256` / `execution_binding_sha256` pair, both lowercase 64-hex digests; absence remains valid for older or self-hosted deployments and does not attest execution. These digests are distinct from both the weights revision and the bundle/config hash. - On scale-from-zero — i.e. no healthy worker registered for the `(bundle, machine_profile)` tuple and the caller did not pin an explicit pool — the gateway records pending demand for KEDA and returns a retryable `503` provisioning response with `Retry-After: 60`, `X-SIE-Error-Code: PROVISIONING`, and gateway version headers. SIE-native surfaces use the SDK retry envelope (`{"error":{"code","message"}}`); OpenAI-compatible surfaces (`/v1/generate`, `/v1/embeddings`, `/v1/chat/completions`, `/v1/completions`, `/v1/responses`) use the OpenAI error envelope, because standard OpenAI clients parse 2xx as successful model output. This applies whether or not the caller set `X-SIE-MACHINE-PROFILE`; default-routing clients get the same contract as profile-pinned clients. - On no-consumer conditions for the JetStream publish, the gateway treats the miss as the same pre-execution provisioning state and returns the same retryable `503 PROVISIONING` contract (`Retry-After: 60`, `X-SIE-Error-Code: PROVISIONING`, and surface-specific error envelope). On backpressure conditions the gateway returns `503` with `Retry-After: 5`. Backpressure is evaluated twice: pool-wide against the `WORK_POOL_{pool}` stream's pending count (`SIE_GATEWAY_MAX_STREAM_PENDING`), and per lane (`pool`/`machine_profile`/`bundle`) against a gateway-local in-flight work-item count (`SIE_GATEWAY_MAX_LANE_IN_FLIGHT_ITEMS`). The per-lane decision is always computed and recorded on `sie.gateway.queue.lane_admission.decisions`, but it only sheds when `SIE_GATEWAY_LANE_BACKPRESSURE_ENFORCE` is set; with the flag off (the default) the pool-wide check is the sole gate. Both sheds produce the same `503` contract and both record pending demand for the exact physical lane so KEDA scales it. - On queue result timeouts the gateway returns `504` with `X-SIE-Error-Code: GATEWAY_TIMEOUT` and `Retry-After: 5`. This means the gateway accepted and published the work item, but no worker result reached the gateway before `SIE_GATEWAY_REQUEST_TIMEOUT`; it must not be collapsed into worker-emitted `MODEL_LOADING`, which remains a separate retryable `503 MODEL_LOADING` signal. diff --git a/packages/sie_gateway/openapi.json b/packages/sie_gateway/openapi.json index e06f3e02e..fedb046d6 100644 --- a/packages/sie_gateway/openapi.json +++ b/packages/sie_gateway/openapi.json @@ -1519,6 +1519,16 @@ } ] }, + "execution_binding_sha256": { + "description": "Worker-origin execution binding, only on a successful terminal event\ntogether with execution_identity_sha256. Both are lowercase SHA-256 digests.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "execution_identity_sha256": { + "description": "Worker-origin execution identity, only on a successful terminal event.\nOptional complete pair with execution_binding_sha256; older or\nself-hosted deployments may omit both. Not a catalog weights revision\nor the executed bundle/config hash.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, "finish_reason": { "type": [ "string", @@ -4158,22 +4168,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -4454,22 +4465,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -4659,22 +4671,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -5250,22 +5263,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -5595,22 +5609,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -5941,22 +5956,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -6266,22 +6282,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -7254,22 +7271,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, @@ -7544,22 +7562,23 @@ } }, "X-SIE-Execution-Binding-SHA256": { - "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "description": "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Execution-Identity-SHA256": { - "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "description": "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", "schema": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "X-SIE-Model-Revision": { - "description": "Immutable deployed bundle/config execution revision that handled the request, when available", + "description": "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", "schema": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, diff --git a/packages/sie_gateway/src/handlers/proxy.rs b/packages/sie_gateway/src/handlers/proxy.rs index 98783cfdd..332894fac 100644 --- a/packages/sie_gateway/src/handlers/proxy.rs +++ b/packages/sie_gateway/src/handlers/proxy.rs @@ -14595,10 +14595,10 @@ mod tests { async fn publish_generate_streaming_sse( &self, - _target: PublishTarget, - _display_model: &str, + target: PublishTarget, + display_model: &str, _engine: &str, - _bundle_config_hash: &str, + bundle_config_hash: &str, _params: &WorkParams, _admission_pool: &str, ) -> Result< @@ -14610,7 +14610,39 @@ mod tests { ), String, > { - unreachable!("bounded target proof uses non-streaming requests") + self.targets + .lock() + .expect("target probe lock") + .push((display_model.to_string(), target)); + let (tx, rx) = oneshot::channel(); + let mut collector = crate::queue::streaming::StreamCollector::new( + tx, + display_model.to_string(), + "default".to_string(), + ); + let tap = collector.install_chunk_tap(); + let terminal = serde_json::from_value(json!({ + "kind": "chunk", "request_id": "request-1", "attempt_id": "attempt-1", + "seq": 0, "text_delta": "ok", "done": true, "is_first": true, + "finish_reason": "stop", + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + "executed_bundle_config_hash": bundle_config_hash, + "execution_identity_sha256": "c".repeat(64), + "execution_binding_sha256": "d".repeat(64) + })) + .unwrap(); + assert_eq!( + collector.apply(terminal), + crate::queue::streaming::ChunkApplied::Terminal + ); + let outcome = collector.build_outcome().unwrap(); + collector.sender.take().unwrap().send(outcome).unwrap(); + Ok(( + "request-1".to_string(), + rx, + tap, + DispatchDurability::accepted(), + )) } async fn publish_cancel(&self, _request_id: &str) {} @@ -15033,6 +15065,78 @@ mod tests { } } + #[tokio::test] + async fn native_sse_revision_contract_distinguishes_catalog_and_execution_evidence() { + let (state, _) = mixed_governed_generation_state(false).await; + let weights_revision = "0123456789abcdef0123456789abcdef01234567"; + state + .model_registry + .add_model_config( + serde_json::from_value(json!({ + "sie_id": "org/g", + "hf_revision": weights_revision, + "profiles": {"default": { + "adapter_path": "sie_server.adapters.sentence_transformer:Adapter", + "max_batch_tokens": 4096 + }} + })) + .unwrap(), + ) + .unwrap(); + let (execution_hash, catalog_revision, _) = state + .model_registry + .bundle_execution_evidence("default", "default", "org/g"); + assert_eq!(catalog_revision.as_deref(), Some(weights_revision)); + assert_eq!(execution_hash.len(), 64); + assert_ne!(execution_hash, weights_revision); + let mut worker = worker_msg("default", "l4", "default"); + worker.bundle_config_hash = execution_hash; + state + .registry + .update_worker("http://worker-l4:8080", worker) + .await; + + let response = proxy_request( + State(state), + json_request( + "/v1/generate/org%2Fg", + json!({ + "prompt": "hello", "max_new_tokens": 4, "stream": true + }), + ), + "generate", + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + for header in [ + "x-sie-model-revision", + "x-sie-execution-identity-sha256", + "x-sie-execution-binding-sha256", + ] { + assert!(!response.headers().contains_key(header)); + } + let bytes = tokio::time::timeout( + Duration::from_secs(5), + axum::body::to_bytes(response.into_body(), 16384), + ) + .await + .unwrap() + .unwrap(); + let body = std::str::from_utf8(&bytes).unwrap(); + let events: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter(|data| *data != "[DONE]") + .map(|data| serde_json::from_str(data).unwrap()) + .collect(); + let terminal = events.iter().find(|event| event["done"] == true).unwrap(); + assert_eq!(terminal["execution_identity_sha256"], "c".repeat(64)); + assert_eq!(terminal["execution_binding_sha256"], "d".repeat(64)); + assert!(body.contains("data: [DONE]")); + assert!(!body.contains(weights_revision)); + } + #[tokio::test] async fn non_streaming_model_rejects_all_streaming_ingresses_before_publish() { let (state, probe) = mixed_governed_generation_state(false).await; diff --git a/packages/sie_gateway/src/openapi.rs b/packages/sie_gateway/src/openapi.rs index 6c2f62c95..650b2c33c 100644 --- a/packages/sie_gateway/src/openapi.rs +++ b/packages/sie_gateway/src/openapi.rs @@ -1358,14 +1358,14 @@ fn inject_inference_response_headers(paths: &mut serde_json::Map) let queue_success_headers = json!({ "X-SIE-Version": header("Gateway package version that handled the request"), "X-SIE-Server-Version": header("Gateway-compatible server version advertised by this gateway"), - "X-SIE-Model-Revision": header( - "Immutable deployed bundle/config execution revision that handled the request, when available", + "X-SIE-Model-Revision": sha256_header( + "Executed bundle/config SHA-256, distinct from the catalog weights revision (for example, a 40-hex Hugging Face commit). Buffered responses only: present when the routing snapshot has a model revision and all successful worker results report its expected config hash. Omitted when evidence is unavailable or mismatched, and always omitted on SSE because headers precede terminal execution evidence.", ), "X-SIE-Execution-Identity-SHA256": sha256_header( - "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available", + "Worker-origin SHA-256 identity of the immutable release and realized serving resources, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", ), "X-SIE-Execution-Binding-SHA256": sha256_header( - "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available", + "Worker-origin runtime-independent SHA-256 binding of the release and deployment route, when available on buffered responses. SSE carries optional execution evidence in the successful terminal event instead of headers.", ), "X-SIE-Request-Id": header("Gateway request id for queue-backed inference"), "X-SIE-Worker": header("Logical queue worker tag that produced the response"), @@ -2029,6 +2029,18 @@ pub struct GenerateChunk { pub logprobs: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// Worker-origin execution identity, only on a successful terminal event. + /// Optional complete pair with execution_binding_sha256; older or + /// self-hosted deployments may omit both. Not a catalog weights revision + /// or the executed bundle/config hash. + #[schema(pattern = "^[0-9a-f]{64}$", nullable = false)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_identity_sha256: Option, + /// Worker-origin execution binding, only on a successful terminal event + /// together with execution_identity_sha256. Both are lowercase SHA-256 digests. + #[schema(pattern = "^[0-9a-f]{64}$", nullable = false)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_binding_sha256: Option, } // ── /v1/chat/completions schemas ────────────────────────────────── @@ -3507,6 +3519,19 @@ mod tests { chunk["properties"]["error"]["oneOf"][1]["$ref"], "#/components/schemas/GenerateChunkError" ); + for field in ["execution_identity_sha256", "execution_binding_sha256"] { + assert_eq!(chunk["properties"][field]["pattern"], "^[0-9a-f]{64}$"); + assert!(!chunk["required"] + .as_array() + .unwrap() + .contains(&json!(field))); + } + let revision = &spec["paths"]["/v1/generate/{model}"]["post"]["responses"]["200"] + ["headers"]["X-SIE-Model-Revision"]; + assert_eq!(revision["schema"]["pattern"], "^[0-9a-f]{64}$"); + let description = revision["description"].as_str().unwrap(); + assert!(description.contains("catalog weights revision")); + assert!(description.contains("always omitted on SSE")); let chunk_error = &spec["components"]["schemas"]["GenerateChunkError"]; assert_eq!( chunk_error["properties"]["param"]["type"], diff --git a/packages/sie_sdk/README.md b/packages/sie_sdk/README.md index 7cd1400ad..304caeaff 100644 --- a/packages/sie_sdk/README.md +++ b/packages/sie_sdk/README.md @@ -49,6 +49,23 @@ client = SIEClient( ) ``` +## Generation execution evidence + +`SIEClient.last_model_revision` retains the `X-SIE-Model-Revision` response +header from the latest call in the current thread. On buffered gateway +responses, this is the lowercase 64-hex executed bundle/config SHA-256 when +worker evidence matches the routing snapshot. It is distinct from a catalog +weights revision such as a 40-hex Hugging Face commit. + +Gateway SSE responses omit that header: headers are sent before terminal +execution evidence is available. Fully consuming `stream_generate()` leaves +`last_model_revision` as `None`. A successful terminal `GenerateChunk` may +instead carry `execution_identity_sha256` and `execution_binding_sha256` as +an optional complete pair of lowercase 64-hex SHA-256 digests. Both Python +clients preserve those fields. Older or self-hosted deployments may omit +both; absence is compatible, but cannot prove which deployment executed. +The terminal digests are distinct from the weights revision and config hash. + ## Object storage and model caches Install the `storage` extra to use `s3://`, `gs://`, `abfs(s)://`, or native diff --git a/packages/sie_sdk/src/sie_sdk/client/sync.py b/packages/sie_sdk/src/sie_sdk/client/sync.py index a74909c6a..24bf24f50 100644 --- a/packages/sie_sdk/src/sie_sdk/client/sync.py +++ b/packages/sie_sdk/src/sie_sdk/client/sync.py @@ -521,7 +521,12 @@ def last_model_revision(self) -> str | None: """Return the deployed execution revision observed on the latest call in this thread. The property name is retained for wire compatibility with - ``X-SIE-Model-Revision``. + ``X-SIE-Model-Revision``. On a gateway buffered response this is the + lowercase 64-hex executed bundle/config SHA-256, not the catalog's + weights revision (for example, a 40-hex Hugging Face commit). + It is ``None`` when the header is absent, including gateway SSE: + headers precede the terminal execution evidence. Fully consuming a + stream does not populate this property from terminal chunk fields. """ value = getattr(self._request_state, "last_model_revision", None) return str(value) if value is not None else None @@ -3012,6 +3017,12 @@ def stream_generate( Yields :class:`GenerateChunk` events; the terminal chunk carries ``done: true`` plus ``usage`` / ``ttft_ms``. Error semantics match :meth:`stream_chat_completions`. + + Successful terminal chunks may carry the optional complete pair + ``execution_identity_sha256`` / ``execution_binding_sha256``. + These worker-origin digests are distinct from the catalog weights + revision and :attr:`last_model_revision`; gateway SSE omits the + ``X-SIE-Model-Revision`` header. """ resolved_grammar = validate_generate_grammar(grammar) if grammar is not None else None pool_name, resolved_gpu = self._resolve_pool_and_gpu(gpu) diff --git a/packages/sie_sdk/src/sie_sdk/types.py b/packages/sie_sdk/src/sie_sdk/types.py index 0e0beff56..d65bb8ce6 100644 --- a/packages/sie_sdk/src/sie_sdk/types.py +++ b/packages/sie_sdk/src/sie_sdk/types.py @@ -658,6 +658,12 @@ class GenerateChunk(TypedDict, total=False): finish_reason: Termination reason (terminal chunk only). usage: Prompt / completion / total token counts (terminal chunk only). ttft_ms: Time-to-first-token in milliseconds (terminal chunk only). + execution_identity_sha256: Optional worker-origin execution identity. + Present only on a successful terminal chunk, together with + ``execution_binding_sha256``; both are lowercase 64-hex SHA-256 + digests. Absence is valid for older or self-hosted deployments. + execution_binding_sha256: Optional worker-origin execution binding, + with the same successful-terminal, complete-pair contract. error: ``{code, message, param?, retry_after_s?}`` when generation failed mid-stream. ``retry_after_s`` is meaningful only for ``RESOURCE_EXHAUSTED``. @@ -671,6 +677,8 @@ class GenerateChunk(TypedDict, total=False): finish_reason: FinishReason usage: GenerationUsage ttft_ms: float + execution_identity_sha256: str + execution_binding_sha256: str error: GenerateChunkError diff --git a/packages/sie_sdk/tests/client/test_chat.py b/packages/sie_sdk/tests/client/test_chat.py index 96afcf512..1353194c5 100644 --- a/packages/sie_sdk/tests/client/test_chat.py +++ b/packages/sie_sdk/tests/client/test_chat.py @@ -18,6 +18,7 @@ import pytest from sie_sdk import SIEClient from sie_sdk.client.errors import RequestError, ResourceExhaustedError, ServerError +from sie_sdk.types import GenerateChunk def _ok_json(payload: dict[str, Any]) -> MagicMock: @@ -234,6 +235,35 @@ def test_stream_generate_yields_chunks_and_normalizes_model_path() -> None: client.close() +@pytest.mark.parametrize("with_evidence", [False, True]) +def test_stream_generate_preserves_optional_terminal_execution_evidence(with_evidence: bool) -> None: + terminal: GenerateChunk = { + "request_id": "request-1", + "seq": 1, + "text_delta": "", + "done": True, + "finish_reason": "stop", + } + if with_evidence: + terminal.update(execution_identity_sha256="a" * 64, execution_binding_sha256="b" * 64) + delta = {"request_id": "request-1", "seq": 0, "text_delta": "Hello", "done": False} + with patch("sie_sdk.client.sync.httpx.Client") as mock_client: + mock_client.return_value.stream.return_value = _FakeStream(lines=_sse(delta, dict(terminal))) + with SIEClient("http://localhost:8080") as client: + client._request_state.last_model_revision = "c" * 64 + chunks: list[GenerateChunk] = list(client.stream_generate("org/model", "hi", max_new_tokens=8)) + assert client.last_model_revision is None + assert chunks[-1] == terminal + assert "execution_identity_sha256" not in chunks[0] + assert "execution_binding_sha256" not in chunks[0] + if with_evidence: + assert chunks[-1]["execution_identity_sha256"] == "a" * 64 + assert chunks[-1]["execution_binding_sha256"] == "b" * 64 + else: + assert "execution_identity_sha256" not in chunks[-1] + assert "execution_binding_sha256" not in chunks[-1] + + def test_stream_generate_validates_extra_body_grammar_before_request() -> None: with patch("sie_sdk.client.sync.httpx.Client") as mc: client = SIEClient("http://localhost:8080") diff --git a/packages/sie_sdk/tests/client/test_chat_async.py b/packages/sie_sdk/tests/client/test_chat_async.py index 4f840fbfe..4d8ee0034 100644 --- a/packages/sie_sdk/tests/client/test_chat_async.py +++ b/packages/sie_sdk/tests/client/test_chat_async.py @@ -14,6 +14,7 @@ import pytest from sie_sdk import SIEAsyncClient from sie_sdk.client.errors import RequestError, ResourceExhaustedError, ServerError +from sie_sdk.types import GenerateChunk class _FakeRaw: @@ -232,6 +233,38 @@ async def test_async_stream_generate_yields_and_normalizes_path() -> None: await client.close() +@pytest.mark.parametrize("with_evidence", [False, True]) +@pytest.mark.asyncio +async def test_stream_generate_preserves_optional_terminal_execution_evidence(with_evidence: bool) -> None: + terminal: GenerateChunk = { + "request_id": "request-1", + "seq": 1, + "text_delta": "", + "done": True, + "finish_reason": "stop", + } + if with_evidence: + terminal.update(execution_identity_sha256="a" * 64, execution_binding_sha256="b" * 64) + delta = {"request_id": "request-1", "seq": 0, "text_delta": "Hello", "done": False} + client = SIEAsyncClient("http://localhost:8080") + _patch_session(client, post_returns=_FakeRaw(line_bytes=_sse_bytes(delta, dict(terminal)))) + try: + chunks: list[GenerateChunk] = [ + chunk async for chunk in client.stream_generate("org/model", "hi", max_new_tokens=8) + ] + finally: + await client.close() + assert chunks[-1] == terminal + assert "execution_identity_sha256" not in chunks[0] + assert "execution_binding_sha256" not in chunks[0] + if with_evidence: + assert chunks[-1]["execution_identity_sha256"] == "a" * 64 + assert chunks[-1]["execution_binding_sha256"] == "b" * 64 + else: + assert "execution_identity_sha256" not in chunks[-1] + assert "execution_binding_sha256" not in chunks[-1] + + @pytest.mark.asyncio async def test_async_stream_generate_validates_extra_body_grammar_before_request() -> None: client = SIEAsyncClient("http://localhost:8080") diff --git a/packages/sie_ts_sdk/src/types.ts b/packages/sie_ts_sdk/src/types.ts index 14c21d7ad..a27d5d79b 100644 --- a/packages/sie_ts_sdk/src/types.ts +++ b/packages/sie_ts_sdk/src/types.ts @@ -1416,6 +1416,14 @@ export interface GenerateChunk { usage?: ChatUsage; /** Time-to-first-token, milliseconds. Terminal chunk only. */ ttft_ms?: number; + /** + * Worker-origin execution identity, only on a successful terminal chunk. + * Optional complete pair with execution_binding_sha256; both are lowercase + * 64-hex SHA-256 digests. Older or self-hosted deployments may omit both. + */ + execution_identity_sha256?: string; + /** Worker-origin execution binding, with the same terminal complete-pair contract. */ + execution_binding_sha256?: string; /** Populated when the worker / gateway errored mid-stream. */ error?: GenerationChunkError; } diff --git a/packages/sie_ts_sdk/tests/streaming.test.ts b/packages/sie_ts_sdk/tests/streaming.test.ts index 1cbd7902b..5e459efee 100644 --- a/packages/sie_ts_sdk/tests/streaming.test.ts +++ b/packages/sie_ts_sdk/tests/streaming.test.ts @@ -573,6 +573,37 @@ describe("SIEClient.streamGenerate", () => { expect(last?.ttft_ms).toBe(123.4); }); + it.each([false, true])( + "preserves optional terminal execution evidence (%s)", + async (withEvidence) => { + const evidence: Partial = withEvidence + ? { execution_identity_sha256: "a".repeat(64), execution_binding_sha256: "b".repeat(64) } + : {}; + mockFetch.mockResolvedValueOnce( + sseResponse([ + generateChunk(0, "Hello"), + generateChunk(1, "", { done: true, finish_reason: "stop", ...evidence }), + ]), + ); + const client = new SIEClient("http://localhost:8080"); + const chunks: GenerateChunk[] = []; + for await (const chunk of client.streamGenerate("org/model", "hi", { maxNewTokens: 8 })) { + chunks.push(chunk); + } + expect(chunks[0]).not.toHaveProperty("execution_identity_sha256"); + expect(chunks[0]).not.toHaveProperty("execution_binding_sha256"); + const terminal = chunks[1]; + expect(terminal?.done).toBe(true); + if (withEvidence) { + expect(terminal?.execution_identity_sha256).toBe(evidence.execution_identity_sha256); + expect(terminal?.execution_binding_sha256).toBe(evidence.execution_binding_sha256); + } else { + expect(terminal).not.toHaveProperty("execution_identity_sha256"); + expect(terminal).not.toHaveProperty("execution_binding_sha256"); + } + }, + ); + it("throws SIEStreamError when chunk.error is present", async () => { mockFetch.mockResolvedValueOnce( sseResponse([ From 7cfa50e73068354808f5b6430c248c494ec2216e Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Wed, 16 Sep 2026 17:09:32 +0200 Subject: [PATCH 2/3] fix(api): constrain native stream execution evidence pairs --- packages/sie_gateway/openapi.json | 24 ++++++++ packages/sie_gateway/src/openapi.rs | 24 ++++++++ packages/sie_sdk/tests/client/test_chat.py | 8 +-- packages/sie_server/openapi.json | 60 +++++++++++++++++++ .../src/sie_server/types/openapi.py | 36 +++++++++++ .../sie_server/tests/test_openapi_export.py | 23 +++++++ .../tests/typecheck/generate-chunk.ts | 21 +++++++ 7 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 packages/sie_ts_sdk/tests/typecheck/generate-chunk.ts diff --git a/packages/sie_gateway/openapi.json b/packages/sie_gateway/openapi.json index fedb046d6..6354cc8dd 100644 --- a/packages/sie_gateway/openapi.json +++ b/packages/sie_gateway/openapi.json @@ -1505,6 +1505,30 @@ }, "GenerateChunk": { "description": "One JSON payload from a SIE-native generation SSE ``data:`` event.\nThe stream is terminated separately by the literal ``data: [DONE]``.", + "oneOf": [ + { + "required": [ + "execution_identity_sha256", + "execution_binding_sha256" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "execution_identity_sha256" + ] + }, + { + "required": [ + "execution_binding_sha256" + ] + } + ] + } + } + ], "properties": { "done": { "type": "boolean" diff --git a/packages/sie_gateway/src/openapi.rs b/packages/sie_gateway/src/openapi.rs index 650b2c33c..77d822587 100644 --- a/packages/sie_gateway/src/openapi.rs +++ b/packages/sie_gateway/src/openapi.rs @@ -278,6 +278,20 @@ fn apply_gateway_openapi_overrides(value: &mut Value) { } }); + if let Some(chunk) = value + .get_mut("components") + .and_then(|components| components.get_mut("schemas")) + .and_then(|schemas| schemas.get_mut("GenerateChunk")) + { + chunk["oneOf"] = json!([ + {"required": ["execution_identity_sha256", "execution_binding_sha256"]}, + {"not": {"anyOf": [ + {"required": ["execution_identity_sha256"]}, + {"required": ["execution_binding_sha256"]} + ]}} + ]); + } + if let Some(create_pool) = value .get_mut("components") .and_then(|components| components.get_mut("schemas")) @@ -3526,6 +3540,16 @@ mod tests { .unwrap() .contains(&json!(field))); } + assert_eq!( + chunk["oneOf"], + json!([ + {"required": ["execution_identity_sha256", "execution_binding_sha256"]}, + {"not": {"anyOf": [ + {"required": ["execution_identity_sha256"]}, + {"required": ["execution_binding_sha256"]} + ]}} + ]) + ); let revision = &spec["paths"]["/v1/generate/{model}"]["post"]["responses"]["200"] ["headers"]["X-SIE-Model-Revision"]; assert_eq!(revision["schema"]["pattern"], "^[0-9a-f]{64}$"); diff --git a/packages/sie_sdk/tests/client/test_chat.py b/packages/sie_sdk/tests/client/test_chat.py index 1353194c5..4b107033b 100644 --- a/packages/sie_sdk/tests/client/test_chat.py +++ b/packages/sie_sdk/tests/client/test_chat.py @@ -237,13 +237,7 @@ def test_stream_generate_yields_chunks_and_normalizes_model_path() -> None: @pytest.mark.parametrize("with_evidence", [False, True]) def test_stream_generate_preserves_optional_terminal_execution_evidence(with_evidence: bool) -> None: - terminal: GenerateChunk = { - "request_id": "request-1", - "seq": 1, - "text_delta": "", - "done": True, - "finish_reason": "stop", - } + terminal = GenerateChunk(request_id="request-1", seq=1, text_delta="", done=True, finish_reason="stop") if with_evidence: terminal.update(execution_identity_sha256="a" * 64, execution_binding_sha256="b" * 64) delta = {"request_id": "request-1", "seq": 0, "text_delta": "Hello", "done": False} diff --git a/packages/sie_server/openapi.json b/packages/sie_server/openapi.json index 6df18a1eb..e9dca4f0f 100644 --- a/packages/sie_server/openapi.json +++ b/packages/sie_server/openapi.json @@ -3038,6 +3038,38 @@ }, "GenerateChunk": { "description": "One SIE-native Server-Sent Event from a streaming generate request.", + "oneOf": [ + { + "properties": { + "execution_identity_sha256": { + "type": "string" + }, + "execution_binding_sha256": { + "type": "string" + } + }, + "required": [ + "execution_identity_sha256", + "execution_binding_sha256" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "execution_identity_sha256" + ] + }, + { + "required": [ + "execution_binding_sha256" + ] + } + ] + } + } + ], "properties": { "request_id": { "description": "Request id shared by every event in the stream", @@ -3127,6 +3159,34 @@ ], "default": null, "description": "Terminal generation error" + }, + "execution_identity_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional worker-origin execution identity, only on a successful terminal event together with execution_binding_sha256. Older or self-hosted deployments may omit both. Distinct from the catalog weights revision and executed bundle/config hash.", + "title": "Execution Identity Sha256" + }, + "execution_binding_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional worker-origin execution binding, with the same successful-terminal complete-pair contract", + "title": "Execution Binding Sha256" } }, "required": [ diff --git a/packages/sie_server/src/sie_server/types/openapi.py b/packages/sie_server/src/sie_server/types/openapi.py index 986bb869f..27cf89356 100644 --- a/packages/sie_server/src/sie_server/types/openapi.py +++ b/packages/sie_server/src/sie_server/types/openapi.py @@ -714,3 +714,39 @@ class GenerateChunk(BaseModel): description="Per-token log probabilities aligned with text_delta", ) error: GenerateChunkErrorModel | None = Field(default=None, description="Terminal generation error") + execution_identity_sha256: str | None = Field( + default=None, + pattern=r"^[0-9a-f]{64}$", + description=( + "Optional worker-origin execution identity, only on a successful terminal event together with " + "execution_binding_sha256. Older or self-hosted deployments may omit both. " + "Distinct from the catalog weights revision and executed bundle/config hash." + ), + ) + execution_binding_sha256: str | None = Field( + default=None, + pattern=r"^[0-9a-f]{64}$", + description="Optional worker-origin execution binding, with the same successful-terminal complete-pair contract", + ) + + model_config = { + "json_schema_extra": { + "oneOf": [ + { + "required": ["execution_identity_sha256", "execution_binding_sha256"], + "properties": { + "execution_identity_sha256": {"type": "string"}, + "execution_binding_sha256": {"type": "string"}, + }, + }, + { + "not": { + "anyOf": [ + {"required": ["execution_identity_sha256"]}, + {"required": ["execution_binding_sha256"]}, + ] + } + }, + ] + } + } diff --git a/packages/sie_server/tests/test_openapi_export.py b/packages/sie_server/tests/test_openapi_export.py index e488e9388..369570809 100644 --- a/packages/sie_server/tests/test_openapi_export.py +++ b/packages/sie_server/tests/test_openapi_export.py @@ -3,6 +3,8 @@ from importlib.metadata import version as pkg_version from pathlib import Path +import pytest +from jsonschema import Draft202012Validator from sie_server.cli import app from typer.testing import CliRunner @@ -248,3 +250,24 @@ def test_openapi_version_from_package() -> None: assert pkg_version("sie-server") == project_version assert spec["info"]["version"] == project_version assert committed_spec["info"]["version"] == project_version + + +@pytest.mark.parametrize("package", ["sie_server", "sie_gateway"]) +@pytest.mark.parametrize( + ("evidence", "valid"), + [ + ({}, True), + ({"execution_identity_sha256": "a" * 64, "execution_binding_sha256": "b" * 64}, True), + ({"execution_identity_sha256": "a" * 64}, False), + ({"execution_binding_sha256": "b" * 64}, False), + ({"execution_identity_sha256": "A" * 64, "execution_binding_sha256": "b" * 64}, False), + ({"execution_identity_sha256": "a" * 63, "execution_binding_sha256": "b" * 64}, False), + ({"execution_identity_sha256": None, "execution_binding_sha256": None}, False), + ], +) +def test_native_stream_schema_requires_complete_execution_evidence(package: str, evidence: dict, valid: bool) -> None: + spec_path = Path(__file__).resolve().parents[3] / "packages" / package / "openapi.json" + spec = json.loads(spec_path.read_text()) + validator = Draft202012Validator(spec["components"]["schemas"]["GenerateChunk"]) + terminal = {"request_id": "request-1", "seq": 1, "text_delta": "", "done": True, **evidence} + assert validator.is_valid(terminal) is valid diff --git a/packages/sie_ts_sdk/tests/typecheck/generate-chunk.ts b/packages/sie_ts_sdk/tests/typecheck/generate-chunk.ts new file mode 100644 index 000000000..bf71444cd --- /dev/null +++ b/packages/sie_ts_sdk/tests/typecheck/generate-chunk.ts @@ -0,0 +1,21 @@ +import type { GenerateChunk } from "../../src/types.js"; + +interface TaggedGenerateChunk extends GenerateChunk { + tag: string; +} + +const terminal: TaggedGenerateChunk = { + request_id: "request-1", + seq: 1, + text_delta: "", + done: true, + tag: "completed", +}; +const attestedTerminal: TaggedGenerateChunk = { + ...terminal, + execution_identity_sha256: "a".repeat(64), + execution_binding_sha256: "b".repeat(64), +}; + +void terminal; +void attestedTerminal; From f256d4e496f066f5cb510e18e3f9ad61aa337387 Mon Sep 17 00:00:00 2001 From: Attila Laszlo Nagy Date: Wed, 16 Sep 2026 17:19:44 +0200 Subject: [PATCH 3/3] fix(api): restrict stream evidence to successful terminal chunks --- packages/sie_gateway/openapi.json | 8 ++++++++ packages/sie_gateway/src/openapi.rs | 10 ++++++++-- packages/sie_server/openapi.json | 6 ++++++ .../sie_server/src/sie_server/types/openapi.py | 2 ++ packages/sie_server/tests/test_openapi_export.py | 14 +++++++++++++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/sie_gateway/openapi.json b/packages/sie_gateway/openapi.json index 6354cc8dd..93d17d6c9 100644 --- a/packages/sie_gateway/openapi.json +++ b/packages/sie_gateway/openapi.json @@ -1507,6 +1507,14 @@ "description": "One JSON payload from a SIE-native generation SSE ``data:`` event.\nThe stream is terminated separately by the literal ``data: [DONE]``.", "oneOf": [ { + "properties": { + "done": { + "const": true + }, + "error": { + "type": "null" + } + }, "required": [ "execution_identity_sha256", "execution_binding_sha256" diff --git a/packages/sie_gateway/src/openapi.rs b/packages/sie_gateway/src/openapi.rs index 77d822587..963193674 100644 --- a/packages/sie_gateway/src/openapi.rs +++ b/packages/sie_gateway/src/openapi.rs @@ -284,7 +284,10 @@ fn apply_gateway_openapi_overrides(value: &mut Value) { .and_then(|schemas| schemas.get_mut("GenerateChunk")) { chunk["oneOf"] = json!([ - {"required": ["execution_identity_sha256", "execution_binding_sha256"]}, + { + "required": ["execution_identity_sha256", "execution_binding_sha256"], + "properties": {"done": {"const": true}, "error": {"type": "null"}} + }, {"not": {"anyOf": [ {"required": ["execution_identity_sha256"]}, {"required": ["execution_binding_sha256"]} @@ -3543,7 +3546,10 @@ mod tests { assert_eq!( chunk["oneOf"], json!([ - {"required": ["execution_identity_sha256", "execution_binding_sha256"]}, + { + "required": ["execution_identity_sha256", "execution_binding_sha256"], + "properties": {"done": {"const": true}, "error": {"type": "null"}} + }, {"not": {"anyOf": [ {"required": ["execution_identity_sha256"]}, {"required": ["execution_binding_sha256"]} diff --git a/packages/sie_server/openapi.json b/packages/sie_server/openapi.json index e9dca4f0f..8cae7e057 100644 --- a/packages/sie_server/openapi.json +++ b/packages/sie_server/openapi.json @@ -3041,6 +3041,12 @@ "oneOf": [ { "properties": { + "done": { + "const": true + }, + "error": { + "type": "null" + }, "execution_identity_sha256": { "type": "string" }, diff --git a/packages/sie_server/src/sie_server/types/openapi.py b/packages/sie_server/src/sie_server/types/openapi.py index 27cf89356..583890bfd 100644 --- a/packages/sie_server/src/sie_server/types/openapi.py +++ b/packages/sie_server/src/sie_server/types/openapi.py @@ -735,6 +735,8 @@ class GenerateChunk(BaseModel): { "required": ["execution_identity_sha256", "execution_binding_sha256"], "properties": { + "done": {"const": True}, + "error": {"type": "null"}, "execution_identity_sha256": {"type": "string"}, "execution_binding_sha256": {"type": "string"}, }, diff --git a/packages/sie_server/tests/test_openapi_export.py b/packages/sie_server/tests/test_openapi_export.py index 369570809..fd95a2b12 100644 --- a/packages/sie_server/tests/test_openapi_export.py +++ b/packages/sie_server/tests/test_openapi_export.py @@ -263,11 +263,23 @@ def test_openapi_version_from_package() -> None: ({"execution_identity_sha256": "A" * 64, "execution_binding_sha256": "b" * 64}, False), ({"execution_identity_sha256": "a" * 63, "execution_binding_sha256": "b" * 64}, False), ({"execution_identity_sha256": None, "execution_binding_sha256": None}, False), + ({"execution_identity_sha256": "a" * 64, "execution_binding_sha256": "b" * 64, "done": False}, False), + ( + { + "execution_identity_sha256": "a" * 64, + "execution_binding_sha256": "b" * 64, + "error": {"code": "INTERNAL", "message": "Generation failed"}, + }, + False, + ), + ({"execution_identity_sha256": "a" * 64, "execution_binding_sha256": "b" * 64, "error": None}, True), + ({"done": False}, True), + ({"error": {"code": "INTERNAL", "message": "Generation failed"}}, True), ], ) def test_native_stream_schema_requires_complete_execution_evidence(package: str, evidence: dict, valid: bool) -> None: spec_path = Path(__file__).resolve().parents[3] / "packages" / package / "openapi.json" spec = json.loads(spec_path.read_text()) - validator = Draft202012Validator(spec["components"]["schemas"]["GenerateChunk"]) + validator = Draft202012Validator({"$ref": "#/components/schemas/GenerateChunk", "components": spec["components"]}) terminal = {"request_id": "request-1", "seq": 1, "text_delta": "", "done": True, **evidence} assert validator.is_valid(terminal) is valid