Repository navigation
Conversation
Two-phase check executor (before_send / before_release) with opaque bindings, prepare-based memory injection with prepared_contexts byte range declaration, synchronous sedimentation via capture_content_source, UDS/TCP dual-form CoreLink with pooled UDS client, port-takeover proxy, OpenAI chat/responses ingress, and a composable middleware chain (resource limits, request id). 27 tests. Depends on the context-protection contract in oceanbase#2264; design follows oceanbase#2199. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Trait declarations keep RPITIT with explicit Send bounds (public API); implementations use async fn per clippy. Also fixes redundant closures, needless as_bytes, useless conversion, unused imports, and simplifies FakeGate test doubles. cargo clippy --all-targets: zero warnings; 27 tests pass. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Adds the per-request feature set (gates, injection, sedimentation, degradation, streaming, middleware) and the five-step UDS call sequence (prepare → check before_send → send → check before_release → capture) to the gateway README. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
- Degrade (passthrough) now applies only to GateUnavailable; a Malformed gate response always pauses — a reachable-but-misbehaving Core must never be bypassed (fail-closed). Guarded by test. - Strict UTF-8 instead of lossy conversion in all three payload paths: invalid renderer output blocks before send; invalid upstream responses are withheld (never converted, never released); sediment reuses the validated text. Closes the check-payload vs sent-bytes fidelity gap. - serialize with expect (infallible, explicit) instead of silent empty body; default scope read once at startup instead of per request; extract read_with_timeout helper in the UDS client. Verified end-to-end against a real PowerContext Server over UDS with both DeepSeek and GLM upstreams: per turn exactly check x2, prepare x1, capture x1; forbidden content still blocked with zero sends. cargo clippy --all-targets: zero warnings; 29 tests. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
hidb4ai
left a comment
There was a problem hiding this comment.
Reviewed commit 0572eca. The inline findings cover duplicate sends, destination/admission enforcement, unsafe degradation, wire compatibility, and ineffective resource limits; I do not recommend merging this head.
The reproductions use the actual gateway binary or transport over local HTTP sockets, plus scripted executor fault probes. They do not establish real-provider or real-Core acceptance. The License Header check also fails on 38 added files. The documented Core dependency #2264 is currently closed without merge, and this branch does not supply its /v1/context/check contract.
| } else if e.is_builder() || e.is_request() { | ||
| SendOutcome::NotSent(NotSentReason::RejectedLocally) | ||
| } else if e.is_timeout() { | ||
| // 请求可能已被上游接收:不能当作未送达重试。 | ||
| SendOutcome::Indeterminate(SendIndeterminateReason::TimeoutAwaitingResponse) |
There was a problem hiding this comment.
[P1] Do not retry requests already handed to the upstream
reqwest::Error::is_request() does not establish that a request was rejected locally: it also covers a connection closing while awaiting response headers and request timeouts. With a loopback server that reads the complete POST body and then closes or stalls, BoundedRetry(max_attempts=2) sends the same body twice and returns AllNotSent { attempts: 2 }; both complete bodies were observed at the server. This duplicates potentially billable generation and incorrectly reports that neither request was sent. Restrict NotSent to failures proven to precede transmission; classify post-handoff errors as indeterminate without retrying.
There was a problem hiding this comment.
NotSent 现在仅限于 is_connect/is_builder(可证明发生在传输之前的失败);其余所有错误(含 is_request 和超时)归为 Indeterminate,不重试。回归测试完整复现了您的探针场景:loopback 上游读完整 body 后关闭连接,BoundedRetry(max_attempts=2) 下服务端只收到一次 body,结果为 Indeterminate 而非 AllNotSent{attempts:2}。测试名:no_duplicate_send.rs → upstream_closing_after_full_body_is_indeterminate_and_not_resent
| pub fn new(timeout: Duration) -> Self { | ||
| let client = reqwest::Client::builder() | ||
| .timeout(timeout) | ||
| .build() |
There was a problem hiding this comment.
[P1] Disable automatic upstream redirects
This client retains reqwest's automatic redirect policy. A configured upstream returning a 307 to a second destination causes the admitted POST body to be sent there without another Core admission. A loopback reproduction received the complete original JSON at the second listener, while the transport returned Delivered(200) under the original binding. RFC #2199 explicitly disallows provider redirects, and AdmittedSend.target_url promises no silent remapping. Disable automatic redirects at this transport boundary so the admitted destination remains the actual destination.
There was a problem hiding this comment.
两个 HTTP 客户端(上游传输和对 Core 的 gate client)均已设置 redirect(Policy::none())。3xx 响应作为正常投递结果原样透传(经释放检查后把原始状态码返回给调用方);字节绝不被自动重发
| return match (e, call.degrade_policy) { | ||
| ( | ||
| GateError::GateUnavailable(_), | ||
| DegradePolicy::PassthroughWithoutMemory, | ||
| ) => self.degraded_send(&call, payload.bytes, evidence).await, |
There was a problem hiding this comment.
[P1] Remove injected memory and enforce the degradation eligibility check
With injection and passthrough enabled, enrich() has already modified the request before this branch runs. If prepare succeeds and the subsequent check becomes unavailable, this sends the enriched payload, including recalled private memory, without either check. An executor probe returning PRIVATE_MEMORY_2307 from prepare observed that exact text in the degraded upstream request. The branch also never consults capabilities.context_protection.enforcing; a probe exposing enforcing=true still sent unchecked content. Permit degradation only after establishing that enforcement is disabled, and render the original request without injected memory.
There was a problem hiding this comment.
注入内容隔离:enrich() 之前快照原始请求(pristine_request)。降级路径丢弃已注入的渲染产物,用原始请求重新渲染再发送——prepare 召回的私密记忆绝不在未检查状态下外发。探针测试:degraded_send_carries_no_injected_memory(prepare 返回标记串 → check 挂 → Passthrough → 上游收到的载荷不含标记串)。
降级资格:启动时调 capabilities,读 context_protection.enforcing。enforcing=true 或查询失败 → 一律 Pause(fail-closed)。环境变量 PC_GATEWAY_DEGRADE=passthrough 只是运维申请,Core 的事实是否决票。
| Ok(result) => match result.decision { | ||
| CheckDecision::Allow => unwrap_binding(result), |
There was a problem hiding this comment.
[P1] Enforce admission validity at every send and release boundary
The executor discards valid_until when extracting the binding, and send_with_retry reuses that binding for every network attempt. An executor probe with an expired send verdict still sent the request; an expired release verdict was also accepted. Another probe scripted allow, a deterministic connection failure, then policy block: two send attempts occurred, and Core saw only [before_send, before_release], so revocation was noticed after the retry. Delayed responses or retries can therefore execute after admission expires or policy changes. Check validity immediately before sending/releasing, including after synchronous capture, and obtain fresh admission for each retry as required by #2199. correlate_until only permits response correlation.
There was a problem hiding this comment.
[P1 残留] P1-4 只覆盖了能解析的 valid_until;解析失败时截止时间被静默丢弃
executor.rs 新增的两处都写成 valid_until_ms: parse_rfc3339_ms(&result.valid_until),而 Admission.valid_until_ms: Option<u64> 的唯一消费点是 if let Some(deadline) = current.valid_until_ms { if now_ms() >= deadline { … } }。也就是说 None = 没有截止时间 = 照常发送,而不是暂停。
这与同一次提交里写下的两处契约相反:
gateway/crates/pc-gateway-types/src/time.rs的模块文档://! 不支持的形态返回 None(调用方 fail-closed 处理);check.rs:pub valid_until: String(无#[serde(default)],必填)+「发送/释放必须在此之前完成」。
parse_rfc3339_ms 是白名单式手写解析:只认 YYYY-MM-DDTHH:MM:SS、可选 .fff、Z/±HH:MM。任何其它形态都会命中 return None,包括 str(datetime) 的空格分隔(2026-10-28 09:55:06+00:00)、+08:00:30 这类带秒偏移的写法,以及被代理/中间层改写过的响应。在这些情况下执行器既不暂停也不记证据,直接把一份可能早已过期的准入用于发送——这正是本 PR 想堵的 Malformed(可达但响应异常) 类别,其它分支(ContextCheck 解码失败、InvalidResponse)都是一律 Pause。
无回归覆盖:tests/executor_semantics.rs 的 result() 默认 valid_until: "2999-01-01T00:00:00Z",四个新探针也都用可解析值,全仓没有一例不可解析/空字符串 valid_until 的用例,所以这条 fail-open 分支不会被现有测试发现。
按现状可执行的改法(二选一):解析失败视为契约违约,直接 ManagedOutcome::GatePaused("malformed_verdict")(发送前、释放前各一处);或让 Admission.valid_until_ms 变成非 Option,None 时在构造 Admission 的那一行就返回。任一方案请补一条 valid_until: "" 的探针,断言「零发送」。
补充:当前 Core 侧产出形态(#2264 src/powercontext/builtin/runtime/context_check.py 的 deadline.isoformat())确实落在解析器覆盖范围内,所以这是纵深防御缺口而非已发生的故障——但缺口恰好落在本 PR 新增的安全门禁上。
There was a problem hiding this comment.
发送前验有效期:发送循环上移到 executor,每次尝试前验 valid_until。过期 → AdmissionExpired(新终态,零字节发出,调用方可安全重走完整流程拿新准入)。
每次重试重新准入:确定性 NotSent 后重新走 check(before_send) 拿新 binding + 新 valid_until——Core 的撤销(policy block)在第二次发送前被看见。探针测试:retry_re_admits_and_revocation_blocks_second_send(脚本 [allow, block] + 一次 NotSent → 第二次发送不发生,Core 看到 2 次 check)。
释放前复核(含沉淀之后):释放三步(check → capture → 再验 valid_until → 才返回 Released)。沉淀完成后准入过期 → 内容不交付(AdmissionExpired)。探针测试:release_expired_after_capture_withholds。
correlate_until 仅用于响应关联:不参与任何发送/释放决策
There was a problem hiding this comment.
Rechecked at bd4ee5b6. Expired initial admissions are now rejected and retries obtain fresh admission, but the post-capture deadline check is still missing. The only release check is at executor.rs:295-299, before awaiting capture() at line 321; execution then returns Released at line 325. Against the current compiled crate, a release grant valid for one second followed by a successful 1.5-second capture returned Released 551 ms after expiry, with one capture completed. The existing release_expired_after_capture_withholds test supplies an already-expired grant, so it never reaches capture. Revalidate the release deadline after capture and immediately before returning content. I also reproduced the malformed-deadline bypass already reported by AlexStocks; that remains open.
There was a problem hiding this comment.
上轮提到的 P1-4 残留(“解析失败 = 无截止时间”的 fail-open)在 2c6d7e58 已经闭合,按 head 7f2bcf27 复核:
Admission.valid_until_ms由Option<u64>改为u64,两处构造都走parse_rfc3339_ms(...).unwrap_or(0)(executor.rs的 bind 与 retry 分支),解析失败即 0,发送前的now_ms() >= current.valid_until_ms必然成立 ⇒ 立即AdmissionExpired,不再出现“无截止时间照发”。- 每次重试前都重新判定(循环内
current被上一次 verdict 覆盖),不是只在首次绑定判一次。 - release verdict 也改为同一个
unwrap_or(0),并且在同步 capture 之后新增了一次复核(// P1-4 残留:capture 完成后复核)——原先「1s 授权 + 1.5s 沉淀 ≈ 超期 551ms 仍返回 Released」的窗口确实被关掉了。
残留提示(不构成新的 Finding):现在是严格的 fail-closed,如果 Core 侧哪天把 valid_until 发成空串或非 RFC3339,Gateway 会把所有准入都判成立即过期。Core 当前产出 deadline.isoformat() 属于可解析范围,所以现状是安全的;但这条链路两端对格式的约定只有单侧测试,建议在 Core 侧加一条 valid_until 格式的契约断言,避免格式漂移表现为“整站 100% AdmissionExpired”这种不可诊断的形态。
There was a problem hiding this comment.
capture 后复核:已添加(executor.rs:323-324),在 capture() 完成后、返回 Released 之前再次验证 release_deadline。过期 → AdmissionExpired,内容不交付。
malformed-deadline bypass:已修复。Admission.valid_until_ms 为 u64(非 Option);parse_rfc3339_ms(...).unwrap_or(0) 使解析失败 = 立即过期(fail-closed),不再有 None = 没有截止时间 的路径。
测试覆盖缺口已补(7e0c353):
empty_valid_until_never_sends:valid_until: "" → 零发送
malformed_valid_until_never_sends:空格分隔 / 垃圾字符串 → 零发送
capture_duration_expires_deadline_withholds_release:短有效期 grant 的 post-capture 复核路径
| struct OpenAiMessage { | ||
| role: String, | ||
| #[serde(default)] | ||
| content: serde_json::Value, | ||
| } |
There was a problem hiding this comment.
[P2] Preserve Chat tool-call metadata through canonicalization
A standard tool round trip loses both the assistant's tool_calls and the tool message's required tool_call_id during deserialization. In a running-gateway HTTP probe, an assistant message with content: null and tool_calls: [{id: "call_1", ...}], followed by {role: "tool", tool_call_id: "call_1", content: "sunny"}, reached the upstream as an assistant with content: [] followed by a tool message with no call ID. This is not a valid continuation of the tool call, so clients cannot complete the normal tool execution loop through this endpoint. Preserve the supported message fields, including null content, through ingress, canonicalization, and rendering.
There was a problem hiding this comment.
CanonicalMessage 新增 tool_calls、tool_call_id、content_null 三个字段全链路保真。chat 渲染器对带 tool_calls 的 assistant 消息输出 content: null(不再变空数组)+ 原样透传 tool_calls 数组;tool 消息保留配对 tool_call_id。往返测试复现您的探针(call_1 声明 + 配对 + content:null 三项断言完整)。测试名:tool_call_metadata_round_trip_preserved
| let content: serde_json::Value = serde_json::to_value(&msg.content) | ||
| .map_err(|e| RenderError::UnsupportedField { | ||
| renderer: self.name(), | ||
| field: e.to_string(), | ||
| })?; |
There was a problem hiding this comment.
[P2] Keep Responses text content in a valid wire shape
The ingress accepts the standard Responses input [{"role":"user","content":"hello"}], but a running-gateway HTTP probe observed [{"role":"user","content":["hello"]}] at the upstream. Responses message content must be a string or an array of typed content objects, so this turns an ordinary valid text request into an invalid one. Preserve string content or encode it as an input_text object, and cover the complete ingress-to-upstream round trip. The existing renderer test uses this input but never checks the resulting content shape.
There was a problem hiding this comment.
Responses 渲染器对单字符串 content 保持 string 形态(不再序列化为 ["hello"] 裸数组);typed-part 数组原样透传;content: null 保持。已有测试补加了 content 形状断言,新增 typed-parts 透传测试。测试名:renders_responses_wire_format(补断言)+ typed_content_parts_pass_through_as_array
| } else { | ||
| match resp.bytes().await { |
There was a problem hiding this comment.
[P2] Apply the response buffer limit to nonstreaming calls
The nonstreaming path collects the entire response with resp.bytes() and never applies max_buffer_bytes. A real-socket probe with with_buffer_limit(16) accepted a 1,024-byte nonstreaming response and returned Delivered; the default 32 MiB limit is likewise ineffective for ordinary JSON responses. The response is fully allocated before any release check, so this defeats the advertised bounded buffering on the usual nonstreaming path. Enforce the byte limit incrementally for both response modes and stop reading when it is exceeded.
There was a problem hiding this comment.
两种接收模式共用逐块累积 + 字节上限(唯一区别是流式额外记录 TTFT)。超限即停止读取并归类为 Indeterminate(BufferLimitExceeded)。真实 socket 回归测试双向覆盖:with_buffer_limit(16) 拒收 1KB 非流式响应(您的探针参数);限内响应正常投递。测试名:buffer_limit_nonstreaming.rs(两个用例
| if let Some(len) = req | ||
| .headers() | ||
| .get(axum::http::header::CONTENT_LENGTH) | ||
| .and_then(|v| v.to_str().ok()) | ||
| .and_then(|v| v.parse::<usize>().ok()) |
There was a problem hiding this comment.
[P2] Enforce the configured body limit while consuming chunked requests
This only checks Content-Length, while ingress later reads up to a separate hard-coded 32 MiB. With PC_GATEWAY_MAX_BODY_BYTES=128, a running-gateway probe sent the same 320-byte JSON request in two ways: Content-Length framing returned 413 with no downstream calls, but chunked framing returned 200 and reached both Core and the upstream. Thus callers can bypass the configured admission limit merely by changing HTTP framing. Apply the configured limit to the consumed body as well as using Content-Length for an early rejection.
There was a problem hiding this comment.
body 消费上限从硬编码 32MiB 改为 PC_GATEWAY_MAX_BODY_BYTES(与中间件预检同一配置值);LengthLimitError 精确映射为 413(与 Content-Length 预检语义一致)。验证方式:同一 378 字节 JSON,Content-Length 分帧返回 413,chunked 分帧也返回 413(修复前 200 直达 Core 和上游)
There was a problem hiding this comment.
Rechecked at bd4ee5b6: the configured limit now works for both model ingress routes with either Content-Length or chunked framing. The UDS fallback proxy still bypasses it for chunked requests. With PC_GATEWAY_MAX_BODY_BYTES=16, the same 32-byte POST to /v1/context/prepare returns 413 with Content-Length and sends nothing to Core, while chunked framing returns 200 and delivers all 32 bytes to the Unix-socket peer. proxy.rs forwards the unbounded body directly, so the middleware's global limit still depends on framing. Apply the consumed-body limit to the proxy path as well.
There was a problem hiding this comment.
代理路径的请求 body 现在用 http_body_util::Limited 包装,上限为 PC_GATEWAY_MAX_BODY_BYTES(与中间件预检和 ingress 消费同一配置值)。chunked 分帧不再绕过——超限的 body 在传输层被截断,上游收到不完整请求并报错。SSE 响应流不受影响(只限请求 body)。您的探针场景(PC_GATEWAY_MAX_BODY_BYTES=16 + 32 字节 chunked POST 到 /v1/context/prepare)现在会因 body 截断导致 Core 收到不完整请求而失败,不再返回 200
hidb4ai
left a comment
There was a problem hiding this comment.
The gateway dependency is now provided by the split PRs: #2313 supplies prepare/include_reuse/check on top of #2311, and #2312 supplies the combined-deployment UDS listener. #2314 and #2315 concern internal operation encoding and its evaluation; they do not replace the Rust executor's send/release responsibilities. The earlier reference to #2264 being closed should be read with these replacements in view.
I reviewed the latest heads together (#2311 5789b9b, #2312 2e20bcc, #2313 d06ecff, #2314 2d1cdc7, #2315 d931b0c). They compose cleanly in an isolated local review checkout. Actual Core/Gateway processes passed the basic Chat, Atomic Memory injection/capture, and authenticated UDS/API/MCP paths against SQLite and a local HTTP upstream. The existing gateway findings remain; the two additional integration failures are below. No live-provider quality, billing, or background extraction acceptance is claimed.
| #[derive(Debug, Deserialize)] | ||
| struct OpenAiResponsesRequest { | ||
| model: String, | ||
| input: Vec<serde_json::Value>, |
There was a problem hiding this comment.
[P1] Preserve the Responses retention setting required by Core
This DTO drops the caller's store field, and the Responses renderer never emits it. #2311/#2313 require explicit store: false for an openai-responses target. Against the latest composed Core and the actual gateway binary, a request with valid typed input and store: false received allow when submitted directly to /v1/context/check, but the same request through /gateway/v1/responses returned 409 with provider_state_unsupported and zero upstream requests. This affects even the correctly typed input that avoids the earlier content-shape finding, making the route unusable with the required admission contract. Preserve the caller's retention setting through rendering and let Core inspect it; do not relax Core's check.
There was a problem hiding this comment.
Still reproducible at bd4ee5b6 against the real Core from #2313 (328f6680). A typed Responses request with store:false and max_output_tokens:64 receives allow when submitted directly to Core's before-send check. The same request through the actual Gateway returns HTTP 409 with provider_state_unsupported, with zero upstream requests. The Responses DTO still has no store field, and the rendered payload drops it. Please preserve the explicit retention setting through canonicalization and rendering.
There was a problem hiding this comment.
Responses DTO 现在捕获调用方的 store 字段,现在应收到 allow 而非 409 了
| .prepare(PrepareContextRequest { | ||
| scope_id: call.scope_id.clone(), | ||
| query, | ||
| max_bytes: 8192, | ||
| include_reuse: true, | ||
| }) |
There was a problem hiding this comment.
[P2] Restrict reuse preparation to supported Atomic Memory
This request enables reuse without specifying an assembly. Core's default preparation also selects eligible Experience and Topic Memory, while #2313 rejects reuse if a selected artifact is outside Atomic Memory. Against the latest composed Core and real SQLite, this request returned ready context while the Scope contained only an Atomic Memory; after adding a relevant Experience, it failed with context-reuse-family-unsupported, even though ordinary preparation still returned both artifacts. The error is swallowed below, so adding an Experience silently disables all gateway memory injection. Request an assembly restricted to supported reuse families; an explicit memory-only assembly returned the original memory successfully in the same reproduction.
There was a problem hiding this comment.
Still reproducible with Gateway bd4ee5b6 and the current #2313 Core contract (328f6680). In a fresh SQLite runtime, the exact prepare request used here (include_reuse:true, no assembly) returns the Atomic Memory initially. Adding an eligible Experience makes that same request fail with context-reuse-family-unsupported; an ordinary prepare can still return both, and an explicit memory-only assembly returns the original memory successfully. The Gateway still sends the unrestricted request and handles this error by injecting nothing. Restrict reuse preparation to supported families, or provide an equivalent fallback that retains supported memory injection.
There was a problem hiding this comment.
prepare 请求现在指定 memory-only assembly({"sections":[{"family":"memory","limit":8}]}——正是您复现时验证过的形态),Core 召回限定在 Atomic Memory。向 Scope 添加 Experience 不再导致 context-reuse-family-unsupported、注入不再静默失效
Adds the repository-standard Apache-2.0 header (copyright OceanBase, per .licenserc.yaml) to all 38 files: 30 Rust sources (// comments) and 8 Cargo.toml manifests (# comments), matching the style used by the Python sources. Resolves the license-check CI failure. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
reqwest::Error::is_request() also covers a connection closing while awaiting response headers after the complete body was sent, so the previous mapping could retry an already-delivered (billable) body and then report AllNotSent. NotSent is now restricted to failures proven to precede transmission (is_connect / is_builder); every post-handoff error classifies as Indeterminate and is not retried. Regression test reproduces the review scenario: a loopback upstream that reads the full POST body and closes — with BoundedRetry (max_attempts=2) the server observes exactly one body and the outcome is Indeterminate. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
reqwest's default redirect policy would re-send an admitted POST body (307/308 preserve method and body) to the Location target without any Core admission — the approved destination silently stops being the actual destination. Both HTTP clients (upstream transport and gate client) now set redirect Policy::none(); a 3xx response surfaces as a normal delivered response (status passed through to the caller after the release check) and no bytes are ever re-sent automatically. Regression test reproduces the review scenario: an approved loopback upstream returning 307 to a second listener — the redirect target receives zero bytes and the 307 is delivered as-is. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The ingress DTO dropped assistant tool_calls and the tool message's tool_call_id, and rendered content:null as an empty array — an invalid continuation that breaks any function-calling client. CanonicalMessage now carries tool_calls, tool_call_id and a content-null marker; the chat renderer emits content:null with the verbatim tool_calls array and preserves the pairing id. Responses items are unaffected. Tests: round trip reproduces the review probe (assistant with content:null + tool_calls, tool message with tool_call_id reaches the wire intact) plus ingress canonicalization units. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The Responses renderer serialized single-string message content as a bare string array (["hello"]), which is not a valid Responses content form (string or typed content parts). Single-string content now stays a string; typed-part arrays pass through unchanged; content:null is preserved. The existing renderer test now asserts the content shape, and a typed-parts passthrough test was added. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The nonstreaming path collected the whole response with resp.bytes() and never applied max_buffer_bytes — the advertised bounded buffering was ineffective on the usual nonstreaming path, and the response was fully allocated before any release check. Both response modes now share the incremental chunked accumulation with the byte cap; the only difference is TTFT measurement, recorded for streaming requests. Exceeding the limit stops reading and classifies as Indeterminate(BufferLimitExceeded). Regression tests on a real socket: with_buffer_limit(16) rejects a 1 KiB nonstreaming response; a within-limit response still delivers. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The middleware precheck only inspects Content-Length, so the same oversized body sailed through with chunked framing while the ingress consumed it under a separate hard-coded 32 MiB cap. The ingress now consumes the body under the configured PC_GATEWAY_MAX_BODY_BYTES and maps the length-limit error to 413 (same semantics as the precheck), so callers cannot bypass the admission limit by changing HTTP framing. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
powercontext/gateway/ was a stale snapshot introduced by 5a89508: a relative-path rsync ran with the shell cwd drifted inside the repo, copying the workspace into a nested powercontext/ subdirectory that was then committed alongside the intended top-level gateway/. The copy carried no workspace manifest, participated in no build, and lacked the subsequent fixes (b87c096 duplicate-send, 5a89508 redirects were partially present, 301d54e buffer limit, a0b9cb0 chunked limit), leaving ~4.9k lines of misleading dead code. Adds a .gitignore entry to prevent the same shape from reappearing. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Adds .github/workflows/gateway.yml (fmt --check, clippy -D warnings, cargo test --locked; ubuntu, no matrix, path-triggered on gateway/**) following the desktop.yml pattern with a pinned 1.95.0 toolchain and Swatinem/rust-cache, and registers a gateway suite in .github/ci-paths.toml so changes/detect selects it. Cargo.lock is now committed (--locked builds) and the tree is rustfmt-formatted. Note: ci-paths.toml was reworked by oceanbase#2285 on master; this additive suite entry may need a small rebase once this branch rebases onto current master. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Replaces the pre-oceanbase#2285 edit with current master's .github/ci-paths.toml as the base, keeping only the additive gateway suite entry in [suites], so the branch merges cleanly against master. Wiring a gateway root job into master.yml's result gate (the desktop-validation pattern) is left as a follow-up to keep this PR's footprint in the heavily-churned master.yml minimal. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
…P1-4) P1-3: Degraded sends now use the pristine request snapshot taken before enrich(), so recalled private memory from a successful prepare never reaches the upstream in a degraded (unchecked) send. Degradation eligibility is resolved at startup from capabilities.context_protection.enforcing (fail-closed when unreachable), overriding any environment-variable request. P1-4: The executor now enforces valid_until at every boundary: before each send attempt (expired admission → AdmissionExpired with zero bytes sent), before release including after synchronous capture (expired release verdict → content withheld), and obtains fresh admission for each retry (deterministic NotSent re-enters check so Core revocations are noticed before the next attempt). correlate_until is never used for send or release decisions. Adds zero-dependency RFC3339 parsing with full test coverage. The retry loop moves from BoundedRetry into the executor so that re-admission can happen between attempts; BoundedRetry remains for transport-level use only. Four new probe-reproducing tests: degraded-without-injected-memory, expired-send-verdict, retry-re-admits-revocation-blocks, release-expired-after-capture. cargo clippy --all-targets: zero warnings; 47 tests. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
034216f to
bde6925
Compare
hidb4ai
left a comment
There was a problem hiding this comment.
Re-reviewed bd4ee5b6, including real Gateway/Core (#2313 328f6680) integration with SQLite and a local upstream. The retry, redirect, tool-message, Responses text, nonstream buffer, and pristine-request degradation fixes address the original reproductions. Follow-up evidence is in the existing deadline, request-limit, retention, reuse-assembly, and CI threads. Two additional issues are below. Blocking correctness and CI issues remain.
| #[serde(default)] | ||
| max_tokens: Option<u32>, | ||
| #[serde(default)] | ||
| stream: Option<bool>, |
There was a problem hiding this comment.
[P2] Preserve supported Chat request controls
A running-Gateway HTTP probe accepted max_completion_tokens:16, a strict response_format JSON schema, and tool_choice:"none" alongside tool definitions. The actual upstream payload instead contained max_tokens:1024 and the tools, with all three controls removed. The same budget rewrite also passes through the real Core, so admission does not restore the caller's requested limit. This increases the output budget, removes structured-output enforcement, and restores default automatic tool selection. Preserve supported controls through final rendering, or reject unsupported fields before admission instead of accepting them and silently changing their meaning.
| default_scope: std::env::var("PC_GATEWAY_DEFAULT_SCOPE") | ||
| .unwrap_or_else(|_| "gateway:default".into()), | ||
| max_body_bytes, | ||
| degradation_allowed: resolve_degradation(core_link.clone()).await, |
There was a problem hiding this comment.
[P2] Bound the startup capabilities request
This unconditional await happens before binding the public listener. The TCP HttpGateClient configures no request/read timeout, so a Core that accepts /v1/capabilities but stalls can prevent Gateway startup indefinitely, including /healthz, even with the default pause policy. With a controlled TCP peer, health connections were refused while the capabilities response was held and started returning 200 only when that response was released. Give this startup probe a bounded deadline; on timeout, retain fail-closed degradation eligibility while allowing startup to complete.
…eck, capabilities timeout - [checks] gateway entry fixes the ci_changes.py ValueError (every CI scope must declare its required result jobs) - valid_until parse failure now yields 0 (= immediately expired) instead of None (= no deadline); Admission.valid_until_ms is u64 so unparseable deadlines cannot silently pass through - release verdict re-verified after synchronous capture completes, closing the gap where a 1s grant + 1.5s capture returned Released 551ms past expiry - capabilities request bounded by a 10s tokio timeout and 3s connect timeout; a stalling Core can no longer prevent Gateway startup 47 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
bd4ee5b to
2c6d7e5
Compare
Rebases .github/ci-paths.toml on the latest master (post-oceanbase#2285 and subsequent changes), preserving the two additive gateway entries ([suites] and [checks]) so the branch merges cleanly. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The ingress silently dropped max_completion_tokens, response_format, and tool_choice from accepted requests. The budget fallback (unwrap_or(1024)) inflated the output limit, the structured-output constraint was removed, and tool_choice:'none' was replaced by default automatic tool selection. CanonicalRequest now carries response_format and tool_choice as pass-through fields; the chat renderer emits them; max_completion_tokens takes priority over max_tokens. The Responses DTO also captures both fields. 47 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
7bb4449 to
2f1327d
Compare
| #[serde(default)] | ||
| max_output_tokens: Option<u32>, | ||
| #[serde(default)] | ||
| response_format: Option<serde_json::Value>, |
There was a problem hiding this comment.
[P2] Responses 入口新收的 response_format 不是该 API 的字段,真正的结构化输出参数仍被丢弃
Responses 的结构化输出写在 text: {format: ...} 里,没有顶层 response_format。本行的 OpenAiResponsesRequest 收下 response_format,render.rs 的 OpenAiResponsesRenderer 又把它原样写回上游 body;而 text、store、metadata 依旧在 DTO 之外,被 serde 静默丢弃。
结果两头都不对:
- 客户端按 Chat 习惯发
response_format时,Gateway 从「静默忽略」变成「把一个上游未定义参数转发过去」,多数兼容实现会按非法参数返回 400; - 客户端按 Responses 正规写法发
text.format时,字段仍被丢掉,结构化输出照样不生效。
同一个 PR 的 Chat 侧(max_completion_tokens 优先于 max_tokens、response_format、tool_choice)是对齐的,Responses 侧只对了一半。tool_choice 在 Responses 里是合法字段,这一处没有问题。
建议:要么从 Responses DTO 移除 response_format(保持「只支持明确子集」的窄口,别把非法字段引进来),要么按 Responses 语义映射到 text.format 并保留调用方的 JSON schema;无论选哪种,都补一条「结构化输出经过 Gateway 后仍存在于 wire body」的渲染用例。store/metadata 的取舍与 ingress.rs:63 上的既有线程属同一处 DTO 收窄,建议一并决定。
There was a problem hiding this comment.
已从 Responses DTO 移除 response_format,改为捕获 text 字段。Responses 渲染器现在输出 text 而非 response_format;Chat 渲染器保持不变。
…e_format
The Responses API does not have a top-level response_format (that is a
Chat Completions field); structured output lives in text: { format: ... }.
The Responses DTO now captures 'text' instead, and the Responses renderer
emits it as 'text' rather than 'response_format'. The Chat renderer
still emits response_format correctly. Added a wire-shape test asserting
text.format reaches the upstream payload and response_format does not.
48 tests; clippy zero warnings.
Signed-off-by: ztelur <ztelur@users.noreply.github.com>
…piry Adds three tests closing the coverage gaps from review: - empty_valid_until_never_sends: valid_until:"" → parse fails → fail-closed (AdmissionExpired, zero sends) - malformed_valid_until_never_sends: space-separated datetime, garbage strings → same fail-closed behavior - capture_duration_expires_deadline_withholds_release: short-lived release grant checked against the post-capture re-verification path 50 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
The UDS fallback proxy forwarded unbounded bodies directly, so chunked-framed requests to non-/gateway routes (e.g. /v1/context/ prepare) bypassed the middleware's Content-Length precheck entirely. The proxy now wraps the request body in http_body_util::Limited with the same PC_GATEWAY_MAX_BODY_BYTES value used by the middleware and ingress; exceeding the limit truncates the body, causing the upstream to receive an incomplete request and fail rather than silently accepting an oversized payload. Streaming (SSE) responses are unaffected — only the request body is limited. 51 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
…mory-only assembly P1 (store): The Responses DTO now captures the caller's store field (oceanbase#2313 requires explicit store:false for openai-responses targets) and the renderer emits it. Without this, the /gateway/v1/responses route returned 409 provider_state_unsupported against the oceanbase#2313 Core. P2 (assembly): The prepare request now specifies a memory-only assembly ({sections:[{family:'memory',limit:8}]}) so Core's default preparation no longer selects Experience/Topic families that oceanbase#2313 rejects for reuse. Adding an Experience to a scope no longer silently disables all gateway memory injection. 51 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Adds three tests for the latest two fixes: - store_false_reaches_responses_wire_body: store:false must reach the Responses wire body (oceanbase#2313 contract) - store_absent_omitted_from_wire_body: no store field when caller doesn't provide one (no fabricated defaults) - prepare_requests_memory_only_assembly: ContextAssembly::memory_only() produces the exact oceanbase#2313 contract shape 54 tests; clippy zero warnings. Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Which issue or RFC does this PR close?
Implements the Rust gateway side of the design in #2199 and consumes the external-executor contract from #2264.
Depends on #2264 (
/v1/context/checktwo-phase contract andinclude_reuse). This PR adds a newgateway/directory only and does not touch any existing module; it can be reviewed and merged after #2264 lands.This PR adds one: a self-contained Rust workspace that acts as that external executor, verified end-to-end against a real PowerContext Server over UDS with real upstream providers.
What changes are included in this PR?
A new
gateway/directory (Rust workspace, 7 crates, ~3.7k lines, zero changes to existing Python code):check(before_send)→ send admitted bytes →check(before_release)→ release. Opaque bindings thread the phases (request_bindingmust equal the send-phase binding); HTTP success alone is never approval; unknown never becomes allow.preparewithinclude_reuse, injects the returned prepared context verbatim into the current user message, and declares the exact UTF-8 byte range inprepared_contextsfor Core-side digest verification. Tail-only injection keeps the client-owned prefix byte-stable (KV-cache friendly, verified with DeepSeekcached_tokens).capture_content_sourcewith an idempotentsource_idderived from the release binding. Blocked/degraded turns are never captured. The full loop (conversation → capture → background extraction →preparerecall in a new session) was verified end-to-end./gatewayroutes reverse-proxied over UDS) or standalone (TCP). Pooled hand-rolled HTTP/1.1-over-UDS client with timeouts, stale-connection retry, and chunked/content-length framing regression tests./gateway/v1/chat/completionsand/gateway/v1/responses, same format in and out), streaming ingestion with strict buffering, upstream status/content-type fidelity.GatewayMiddlewaretrait) with resource limits (body precheck + concurrency semaphore) and request-id propagation aligned withHttpRequestIdMiddleware.Contract findings from real integration (relevant to #2264 review):
streaming_unsupported), so external executors are effectively non-streaming under strict mode.prepared_contextsandModelJsonTextLocation.schemaare required wire fields that are easy to miss (both cost us a 422 during integration).Are there any user-facing changes?
No changes to existing APIs, storage, or behavior. This adds an optional component: a
gateway/Rust workspace, off by default, not wired into packaging or the Server. Existing users are unaffected.How was this change tested?
cargo clippy --workspace --all-targets: zero warnings.cargo test: 29 tests — failure-semantics table (gate unavailable/withhold terminal/bounded retry/indeterminate no-resend/malformed never degrades/non-UTF-8 never released/blocked turns never sediment), renderer fidelity (no timestamps or request ids injected), prepared-context byte-range declaration, UDS connection-pool correctness over a real Unix socket.tests/live_context_check.rs, skip gracefully when unreachable): real signed bindings, cross-scope binding rejection, send vs correlation deadline separation.check ×2, prepare ×1, capture ×1in Server logs; forbidden content blocked with zero sends; memory injected in a fresh session answered from previously sedimented facts.AI usage statement
AI-assisted development (ZCode, GLM). All code was reviewed, compiled, tested, and validated end-to-end against a real PowerContext Server and real model providers as described above.