From f581899bb89609b67b53bb1959905d56e68e53ca Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:28:37 +0200 Subject: [PATCH] feat(llm): gate broker on coding-agent protocol version (WI-921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject /complete requests whose X-Protocol-Version is missing or mismatched with 426 Upgrade Required so an out-of-step agent fails loudly and diagnostically instead of misparsing the response. 💘 Generated with Crush Assisted-by: Crush:deepseek/deepseek-v4-flash-0731 --- internal/handlers/errors.go | 11 +++++++++++ internal/handlers/runner_broker.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index eeb2edcd..6c5fcc4c 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -178,3 +178,14 @@ func respondServiceUnavailable(w http.ResponseWriter, r *http.Request, message s err := restapi.NewAPIError(http.StatusServiceUnavailable, restapi.ErrCodeServiceUnavailable, message) restapi.RespondError(w, r, err) } + +// respondUpgradeRequired writes a 426 Upgrade Required JSON response. Used by +// the llm-proxy broker when a coding-agent client presents a missing or +// mismatched protocol version, so an out-of-step agent fails loudly with a +// diagnostic signal instead of misparsing the response (WI-921). The caller +// advertises the supported version in the X-Protocol-Version response header. +func respondUpgradeRequired(w http.ResponseWriter, r *http.Request) { + err := restapi.NewAPIError(http.StatusUpgradeRequired, "PROTOCOL_VERSION_MISMATCH", + "coding-agent protocol version is out of date; the agent image must match the server version") + restapi.RespondError(w, r, err) +} diff --git a/internal/handlers/runner_broker.go b/internal/handlers/runner_broker.go index b672f8cc..7e334533 100644 --- a/internal/handlers/runner_broker.go +++ b/internal/handlers/runner_broker.go @@ -35,6 +35,16 @@ const ( // egressResponseHeaderTimeout bounds time-to-first-header for arbitrary // HTTP/git egress, where a slow upstream is treated as a fault. egressResponseHeaderTimeout = 30 * time.Second + + // brokerProtocolVersion identifies the non-streaming inference contract the + // broker speaks. The coding-agent client must send this exact value in the + // X-Protocol-Version header on every /complete request; the broker rejects + // a missing or newer/mismatched version with 426 Upgrade Required so an + // out-of-step agent fails loudly and diagnostically instead of misparsing + // the response (WI-921). Bump it when CompletionRequest/CompletionResponse + // changes incompatibly. + brokerProtocolVersion = "1" + protocolVersionHeader = "X-Protocol-Version" // llmResponseHeaderTimeout is intentionally generous: an OpenAI-compatible // chat completion can spend minutes on prompt prefill (long context, // reasoning) before committing the SSE response headers. A 30s bound aborts @@ -171,6 +181,11 @@ func (h *RunnerBrokerHandler) ProxyLLM(w http.ResponseWriter, r *http.Request) { respondForbidden(w, r) return } + if v := r.Header.Get(protocolVersionHeader); v != brokerProtocolVersion { + w.Header().Set(protocolVersionHeader, brokerProtocolVersion) + respondUpgradeRequired(w, r) + return + } cfg, err := h.llmConns.ConnectionRuntime(r.Context(), grants.LLM.ConnectionID) if err != nil { respondServiceUnavailable(w, r, "llm connection unavailable")