Skip to content

Commit 06c2ee0

Browse files
committed
fix: recover from unknown opaque state after affinity loss
1 parent c2e798d commit 06c2ee0

6 files changed

Lines changed: 197 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ X-Grok-Session-ID: conversation-123
316316

317317
`previous_response_id`、显式 session ID 和状态签名属于 hard affinity;`GROK_AUTHS_DIR` 下的 `.grokcli2api-affinity.json` 只保存加入 tenant namespace 后的哈希 binding、账号、模型、backend、上游 session、下一 turn 与过期时间。`prompt_cache_key`、用户字段等 soft affinity 与 `store:false` 工具回放仍只保存在内存中。所有映射都受 TTL 与容量上限控制;本地 API Key 不直接选择账号,但会隔离不同 tenant 的 affinity、Response ownership、状态签名与工具回放。客户端 IP 不参与亲和。
318318

319+
容器重建或 affinity 状态丢失后,请求中的 Responses `reasoning.encrypted_content` 或 Anthropic thinking signature 可能在当前 tenant 中没有 binding。服务会删除这部分未知 opaque state,并以新上游 session 继续其余对话,而不会把密文转发给随机账号;已经存在但互相指向不同账号/session 的有效 binding 仍会被拒绝。未知 `previous_response_id` 继续返回 404。
320+
319321
## 配置说明
320322

321323
程序会从当前工作目录的 `.env` 文件中加载尚未设置的环境变量。完整模板与高级客户端标识选项见 [`.env.example`](.env.example)。

‎README_EN.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ The service recognizes affinity inputs in this priority order:
316316

317317
`previous_response_id`, an explicit session ID, and state signatures create hard affinity. The `.grokcli2api-affinity.json` file under `GROK_AUTHS_DIR` contains only the tenant-namespaced hashed binding, account, model, backend, upstream session, next turn, and expiry. Soft affinity from cache/user fields and `store:false` tool replay remain in memory only. Every mapping is bounded by the configured TTL and capacity. A local API key does not directly choose an account, but it isolates affinity, Response ownership, state signatures, and replay data between tenants; client IP addresses are not used.
318318

319+
After a container rebuild or affinity-state loss, Responses `reasoning.encrypted_content` or an Anthropic thinking signature may have no binding in the current tenant. The service removes only that unknown opaque state and continues the remaining conversation with a fresh upstream session instead of forwarding ciphertext through a random account. Valid bindings that point to different accounts or sessions are still rejected. An unknown `previous_response_id` continues to return 404.
320+
319321
## Configuration
320322

321323
The service loads environment variables that are not already set from a `.env` file in the current working directory. See [`.env.example`](.env.example) for the complete template and advanced client-identity options.

‎internal/inference/plan.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,79 @@ func (p *RequestPlan) StateHandles() []StateHandle {
151151
return append([]StateHandle(nil), p.stateHandles...)
152152
}
153153

154+
// WithoutOpaqueState returns an immutable copy of the plan with the selected
155+
// opaque continuation handles removed. A missing local ownership binding can
156+
// happen after affinity state is lost during an upgrade or container rebuild;
157+
// the ciphertext must not be forwarded through a newly selected account, but
158+
// the remaining public conversation can safely start a fresh upstream session.
159+
func (p *RequestPlan) WithoutOpaqueState(handles []StateHandle) *RequestPlan {
160+
if p == nil || len(handles) == 0 {
161+
return p
162+
}
163+
dropped := make(map[string]struct{}, len(handles))
164+
for _, handle := range handles {
165+
if handle.Kind != StateOpaqueToken {
166+
continue
167+
}
168+
if value := strings.TrimSpace(handle.Value); value != "" {
169+
dropped[value] = struct{}{}
170+
}
171+
}
172+
if len(dropped) == 0 {
173+
return p
174+
}
175+
176+
body := cloneMap(p.body)
177+
switch p.protocol {
178+
case ProtocolResponses:
179+
input, _ := body["input"].([]any)
180+
clean := make([]any, 0, len(input))
181+
for _, rawItem := range input {
182+
item, _ := rawItem.(map[string]any)
183+
if strings.EqualFold(trimmedString(item["type"]), "reasoning") {
184+
if encrypted, ok := item["encrypted_content"].(string); ok {
185+
if _, remove := dropped[strings.TrimSpace(encrypted)]; remove {
186+
continue
187+
}
188+
}
189+
}
190+
clean = append(clean, rawItem)
191+
}
192+
if input != nil {
193+
body["input"] = clean
194+
}
195+
case ProtocolMessages:
196+
messages, _ := body["messages"].([]any)
197+
for _, rawMessage := range messages {
198+
message, _ := rawMessage.(map[string]any)
199+
parts, _ := message["content"].([]any)
200+
if parts == nil {
201+
continue
202+
}
203+
clean := make([]any, 0, len(parts))
204+
for _, rawPart := range parts {
205+
part, _ := rawPart.(map[string]any)
206+
kind := trimmedString(part["type"])
207+
if strings.EqualFold(kind, "thinking") || strings.EqualFold(kind, "redacted_thinking") {
208+
if signature, ok := part["signature"].(string); ok {
209+
if _, remove := dropped[strings.TrimSpace(signature)]; remove {
210+
continue
211+
}
212+
}
213+
}
214+
clean = append(clean, rawPart)
215+
}
216+
message["content"] = clean
217+
}
218+
}
219+
220+
copy := *p
221+
copy.body = body
222+
copy.stateHandles = requestStateHandles(copy.protocol, body)
223+
copy.hasState = len(copy.stateHandles) > 0
224+
return &copy
225+
}
226+
154227
// NewRequestPlan validates only public required structure and fields that are
155228
// intentionally retained. Unsupported fields and content are left for the
156229
// account-specific renderer to discard silently.

‎internal/inference/plan_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,58 @@ func TestEncryptedResponsesReasoningIsStatefulOnlyOnNativeBackend(t *testing.T)
348348
}
349349
}
350350

351+
func TestWithoutOpaqueStateReturnsSanitizedImmutablePlan(t *testing.T) {
352+
t.Run("responses", func(t *testing.T) {
353+
plan := mustPlan(t, ProtocolResponses, map[string]any{
354+
"model": "m", "input": []any{
355+
map[string]any{"type": "reasoning", "encrypted_content": "lost-state"},
356+
map[string]any{"type": "reasoning", "encrypted_content": "owned-state"},
357+
map[string]any{"type": "message", "role": "user", "content": "continue"},
358+
},
359+
})
360+
clean := plan.WithoutOpaqueState([]StateHandle{{Kind: StateOpaqueToken, Value: "lost-state"}})
361+
if len(plan.StateHandles()) != 2 || len(clean.StateHandles()) != 1 {
362+
t.Fatalf("state handles original=%#v clean=%#v", plan.StateHandles(), clean.StateHandles())
363+
}
364+
original, err := plan.Render(modelcatalog.ModelDescriptor{Backend: modelcatalog.BackendResponses})
365+
if err != nil || !contains(outputString(original.Body, false), "lost-state") {
366+
t.Fatalf("original plan was mutated: body=%#v err=%v", original, err)
367+
}
368+
attempt, err := clean.Render(modelcatalog.ModelDescriptor{Backend: modelcatalog.BackendResponses})
369+
if err != nil {
370+
t.Fatal(err)
371+
}
372+
encoded := outputString(attempt.Body, false)
373+
if contains(encoded, "lost-state") || !contains(encoded, "owned-state") || !attempt.PreservesState {
374+
t.Fatalf("sanitized attempt=%#v encoded=%s", attempt, encoded)
375+
}
376+
})
377+
378+
t.Run("messages", func(t *testing.T) {
379+
plan := mustPlan(t, ProtocolMessages, map[string]any{
380+
"model": "m", "max_tokens": float64(32), "messages": []any{
381+
map[string]any{"role": "assistant", "content": []any{
382+
map[string]any{"type": "redacted_thinking", "data": "opaque", "signature": "lost-signature"},
383+
map[string]any{"type": "thinking", "thinking": "keep", "signature": "owned-signature"},
384+
}},
385+
map[string]any{"role": "user", "content": "continue"},
386+
},
387+
})
388+
clean := plan.WithoutOpaqueState([]StateHandle{{Kind: StateOpaqueToken, Value: "lost-signature"}})
389+
if len(plan.StateHandles()) != 2 || len(clean.StateHandles()) != 1 {
390+
t.Fatalf("state handles original=%#v clean=%#v", plan.StateHandles(), clean.StateHandles())
391+
}
392+
attempt, err := clean.Render(modelcatalog.ModelDescriptor{Backend: modelcatalog.BackendMessages})
393+
if err != nil {
394+
t.Fatal(err)
395+
}
396+
encoded := outputString(attempt.Body, false)
397+
if contains(encoded, "lost-signature") || !contains(encoded, "owned-signature") || !attempt.PreservesState {
398+
t.Fatalf("sanitized attempt=%#v encoded=%s", attempt, encoded)
399+
}
400+
})
401+
}
402+
351403
func TestCleaningErrorsOnlyWhenNoMinimumInputRemains(t *testing.T) {
352404
plan := mustPlan(t, ProtocolResponses, map[string]any{
353405
"model": "m", "input": []any{

‎internal/server/continuity_e2e_test.go‎

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,28 @@ import (
1616
"github.com/Futureppo/grokcli2api-go/internal/modelcatalog"
1717
)
1818

19-
func TestOpaqueStateOwnershipFailsClosedBeforeUpstream(t *testing.T) {
19+
func TestOpaqueStateOwnershipDropsUnknownBeforeUpstream(t *testing.T) {
2020
var inferenceCalls atomic.Int32
21+
var mu sync.Mutex
22+
var inferenceBodies []map[string]any
2123
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2224
if r.Method == http.MethodGet {
2325
writeTestCatalog(w, modelcatalog.BackendResponses)
2426
return
2527
}
2628
inferenceCalls.Add(1)
27-
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "unexpected inference"})
29+
var body map[string]any
30+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
31+
t.Errorf("decode inference: %v", err)
32+
return
33+
}
34+
mu.Lock()
35+
inferenceBodies = append(inferenceBodies, body)
36+
mu.Unlock()
37+
writeJSON(w, http.StatusOK, map[string]any{
38+
"id": "resp-ok", "object": "response", "status": "completed", "output": []any{},
39+
"usage": map[string]any{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
40+
})
2841
}))
2942
defer upstream.Close()
3043

@@ -36,54 +49,76 @@ func TestOpaqueStateOwnershipFailsClosedBeforeUpstream(t *testing.T) {
3649
tenantA := s.pool.TenantID("tenant-a-key")
3750
tenantB := s.pool.TenantID("tenant-b-key")
3851

39-
// A Messages thinking signature owned by tenant A must not be forwarded by
40-
// tenant B when the selected account preserves it on a Responses backend.
52+
// A Messages thinking signature owned by tenant A is unknown to tenant B.
53+
// Drop it and start fresh instead of forwarding it through another account.
4154
s.continuity.BindStateToken(tenantA, "foreign-signature", accounts[0], "grok-4", modelcatalog.BackendResponses, "session-a", 1)
42-
assertStateRejected(t, s, "tenant-b-key", "/v1/messages", `{
55+
assertStateStatus(t, s, "tenant-b-key", "/v1/messages", `{
4356
"model":"grok-4","max_tokens":8,
4457
"messages":[
4558
{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":"foreign-signature"}]},
4659
{"role":"user","content":"continue"}
4760
]
48-
}`, "", http.StatusNotFound)
61+
}`, "", http.StatusOK)
4962

50-
// Checking every retained item prevents an owned-first/foreign-second
51-
// encrypted reasoning sequence from bypassing tenant isolation.
63+
// Keep the locally owned state that selects the route while dropping an
64+
// additional token which is unknown in this tenant.
5265
s.continuity.BindStateToken(tenantB, "owned-ciphertext", accounts[0], "grok-4", modelcatalog.BackendResponses, "session-b", 2)
5366
s.continuity.BindStateToken(tenantA, "foreign-ciphertext", accounts[0], "grok-4", modelcatalog.BackendResponses, "session-a", 2)
54-
assertStateRejected(t, s, "tenant-b-key", "/v1/responses", `{
67+
assertStateStatus(t, s, "tenant-b-key", "/v1/responses", `{
5568
"model":"grok-4","input":[
5669
{"type":"reasoning","encrypted_content":"owned-ciphertext"},
5770
{"type":"reasoning","encrypted_content":"foreign-ciphertext"},
5871
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
5972
]
60-
}`, "", http.StatusNotFound)
73+
}`, "", http.StatusOK)
6174

62-
// An explicit client session cannot hide a stolen opaque token behind its
63-
// higher affinity priority.
75+
// An explicit client session remains authoritative while the unrelated
76+
// unknown token is removed from its request.
6477
sessionAffinity := auth.Affinity{Tenant: tenantB, Key: "session:client-session", Mode: auth.AffinityHard}
6578
s.continuity.Bind(continuityKey(tenantB, sessionAffinity, "grok-4"), sessionAffinity, accounts[0], "grok-4", modelcatalog.BackendResponses, "session-b", 3)
66-
assertStateRejected(t, s, "tenant-b-key", "/v1/responses", `{
79+
assertStateStatus(t, s, "tenant-b-key", "/v1/responses", `{
6780
"model":"grok-4","input":[
6881
{"type":"reasoning","encrypted_content":"foreign-ciphertext"},
6982
{"type":"message","role":"user","content":"continue"}
7083
]
71-
}`, "client-session", http.StatusNotFound)
84+
}`, "client-session", http.StatusOK)
7285

7386
// Two valid handles for the same tenant still cannot be combined when they
7487
// belong to different upstream accounts/sessions.
7588
s.continuity.BindStateToken(tenantB, "route-one", accounts[0], "grok-4", modelcatalog.BackendResponses, "session-one", 4)
7689
s.continuity.BindStateToken(tenantB, "route-two", accounts[1], "grok-4", modelcatalog.BackendResponses, "session-two", 4)
77-
assertStateRejected(t, s, "tenant-b-key", "/v1/responses", `{
90+
assertStateStatus(t, s, "tenant-b-key", "/v1/responses", `{
7891
"model":"grok-4","input":[
7992
{"type":"reasoning","encrypted_content":"route-one"},
8093
{"type":"reasoning","encrypted_content":"route-two"},
8194
{"type":"message","role":"user","content":"continue"}
8295
]
8396
}`, "", http.StatusServiceUnavailable)
8497

85-
if got := inferenceCalls.Load(); got != 0 {
86-
t.Fatalf("unsafe state reached upstream %d times", got)
98+
if got := inferenceCalls.Load(); got != 3 {
99+
t.Fatalf("inference calls=%d, want 3 safe degraded requests", got)
100+
}
101+
mu.Lock()
102+
defer mu.Unlock()
103+
if len(inferenceBodies) != 3 {
104+
t.Fatalf("inference bodies=%d, want 3", len(inferenceBodies))
105+
}
106+
encoded := make([]string, len(inferenceBodies))
107+
for index, body := range inferenceBodies {
108+
data, err := json.Marshal(body)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
encoded[index] = string(data)
113+
}
114+
if strings.Contains(encoded[0], "foreign-signature") {
115+
t.Fatalf("foreign Messages state reached upstream: %s", encoded[0])
116+
}
117+
if !strings.Contains(encoded[1], "owned-ciphertext") || strings.Contains(encoded[1], "foreign-ciphertext") {
118+
t.Fatalf("mixed state was not cleaned safely: %s", encoded[1])
119+
}
120+
if strings.Contains(encoded[2], "foreign-ciphertext") {
121+
t.Fatalf("foreign session state reached upstream: %s", encoded[2])
87122
}
88123
}
89124

@@ -169,7 +204,7 @@ func TestDroppedStateStartsFreshWithoutFalseOwnershipError(t *testing.T) {
169204
}
170205
}
171206

172-
func assertStateRejected(t *testing.T, s *Server, apiKey, path, body, session string, wantStatus int) {
207+
func assertStateStatus(t *testing.T, s *Server, apiKey, path, body, session string, wantStatus int) {
173208
t.Helper()
174209
recorder := httptest.NewRecorder()
175210
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))

‎internal/server/inference_execution.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"io"
8+
"log/slog"
89
"net/http"
910
"sort"
1011
"strings"
@@ -27,6 +28,7 @@ func (e *continuityRequestError) Error() string { return e.Message }
2728
type inferenceExecution struct {
2829
tenant string
2930
model string
31+
plan *inference.RequestPlan
3032
affinity auth.Affinity
3133
pinned string
3234
expectedBackend modelcatalog.Backend
@@ -50,7 +52,7 @@ type continuityLookup struct {
5052

5153
func (s *Server) prepareInferenceExecution(r *http.Request, body map[string]any, plan *inference.RequestPlan) (inferenceExecution, error) {
5254
tenant := tenantFromContext(r.Context())
53-
execution := inferenceExecution{tenant: tenant, model: plan.Model(), affinity: requestSoftAffinity(r, body)}
55+
execution := inferenceExecution{tenant: tenant, model: plan.Model(), plan: plan, affinity: requestSoftAffinity(r, body)}
5456
execution.identity = grok.RequestIdentity{RequestID: grok.NewID(), SessionID: newLogicalSession()}
5557
execution.identity.ConversationID = execution.identity.SessionID
5658
turn := uint64(0)
@@ -63,11 +65,21 @@ func (s *Server) prepareInferenceExecution(r *http.Request, body map[string]any,
6365
}
6466

6567
state := make([]continuityLookup, 0, len(plan.StateHandles()))
68+
unknownOpaque := make([]inference.StateHandle, 0)
6669
for _, handle := range plan.StateHandles() {
6770
affinity := stateHandleAffinity(tenant, handle)
6871
key, binding, found := s.continuity.Lookup(tenant, affinity, plan.Model())
72+
if handle.Kind == inference.StateOpaqueToken && !found {
73+
unknownOpaque = append(unknownOpaque, handle)
74+
continue
75+
}
6976
state = append(state, continuityLookup{handle: handle, affinity: affinity, key: key, binding: binding, found: found})
7077
}
78+
if len(unknownOpaque) > 0 {
79+
plan = plan.WithoutOpaqueState(unknownOpaque)
80+
execution.plan = plan
81+
slog.Warn("unknown opaque upstream state dropped", "model", plan.Model(), "protocol", plan.Protocol(), "count", len(unknownOpaque))
82+
}
7183

7284
// An existing explicit session is authoritative. Only state tokens which
7385
// that account's renderer will actually retain need ownership validation;
@@ -326,6 +338,7 @@ func (s *Server) executeNonStreaming(w http.ResponseWriter, r *http.Request, bod
326338
s.writePlanError(w, plan.Protocol(), err)
327339
return
328340
}
341+
plan = execution.plan
329342
result, err := s.client.DoInference(r.Context(), plan, grok.InferenceOptions{
330343
Affinity: execution.affinity, PinnedAccount: execution.pinned, ExpectedBackend: execution.expectedBackend, Identity: execution.identity,
331344
})
@@ -365,6 +378,7 @@ func (s *Server) executeStreaming(w http.ResponseWriter, r *http.Request, body m
365378
s.writePlanError(w, plan.Protocol(), err)
366379
return
367380
}
381+
plan = execution.plan
368382
stream, err := s.client.OpenInference(r.Context(), plan, grok.InferenceOptions{
369383
Affinity: execution.affinity, PinnedAccount: execution.pinned, ExpectedBackend: execution.expectedBackend, Identity: execution.identity,
370384
})

0 commit comments

Comments
 (0)