Skip to content

Commit a325533

Browse files
authored
Merge pull request router-for-me#2972 from XYenon/feat/amp-thread-id
feat: support X-Amp-Thread-Id for session affinity
2 parents 736ae61 + 8e49c79 commit a325533

3 files changed

Lines changed: 70 additions & 6 deletions

File tree

sdk/api/handlers/handlers.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,19 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
223223
return meta
224224
}
225225

226+
// headersFromContext extracts the original HTTP request headers from the gin context
227+
// embedded in the provided context. This allows session affinity selectors to read
228+
// client headers like X-Amp-Thread-Id.
229+
func headersFromContext(ctx context.Context) http.Header {
230+
if ctx == nil {
231+
return nil
232+
}
233+
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
234+
return ginCtx.Request.Header.Clone()
235+
}
236+
return nil
237+
}
238+
226239
func pinnedAuthIDFromContext(ctx context.Context) string {
227240
if ctx == nil {
228241
return ""
@@ -508,6 +521,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
508521
Alt: alt,
509522
OriginalRequest: rawJSON,
510523
SourceFormat: sdktranslator.FromString(handlerType),
524+
Headers: headersFromContext(ctx),
511525
}
512526
opts.Metadata = reqMeta
513527
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
@@ -555,6 +569,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
555569
Alt: alt,
556570
OriginalRequest: rawJSON,
557571
SourceFormat: sdktranslator.FromString(handlerType),
572+
Headers: headersFromContext(ctx),
558573
}
559574
opts.Metadata = reqMeta
560575
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
@@ -606,6 +621,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
606621
Alt: alt,
607622
OriginalRequest: rawJSON,
608623
SourceFormat: sdktranslator.FromString(handlerType),
624+
Headers: headersFromContext(ctx),
609625
}
610626
opts.Metadata = reqMeta
611627
streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)

sdk/cliproxy/auth/selector.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -570,9 +570,10 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) {
570570
// Priority order:
571571
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients
572572
// 2. X-Session-ID header
573-
// 3. metadata.user_id (non-Claude Code format)
574-
// 4. conversation_id field in request body
575-
// 5. Stable hash from first few messages content (fallback)
573+
// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
574+
// 4. metadata.user_id (non-Claude Code format)
575+
// 5. conversation_id field in request body
576+
// 6. Stable hash from first few messages content (fallback)
576577
func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string {
577578
primary, _ := extractSessionIDs(headers, payload, metadata)
578579
return primary
@@ -608,22 +609,29 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
608609
}
609610
}
610611

612+
// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
613+
if headers != nil {
614+
if tid := headers.Get("X-Amp-Thread-Id"); tid != "" {
615+
return "amp:" + tid, ""
616+
}
617+
}
618+
611619
if len(payload) == 0 {
612620
return "", ""
613621
}
614622

615-
// 3. metadata.user_id (non-Claude Code format)
623+
// 4. metadata.user_id (non-Claude Code format)
616624
userID := gjson.GetBytes(payload, "metadata.user_id").String()
617625
if userID != "" {
618626
return "user:" + userID, ""
619627
}
620628

621-
// 4. conversation_id field
629+
// 5. conversation_id field
622630
if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" {
623631
return "conv:" + convID, ""
624632
}
625633

626-
// 5. Hash-based fallback from message content
634+
// 6. Hash-based fallback from message content
627635
return extractMessageHashIDs(payload)
628636
}
629637

sdk/cliproxy/auth/selector_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,46 @@ func TestExtractSessionID_Headers(t *testing.T) {
776776
}
777777
}
778778

779+
func TestExtractSessionID_AmpThreadId(t *testing.T) {
780+
t.Parallel()
781+
782+
headers := make(http.Header)
783+
headers.Set("X-Amp-Thread-Id", "T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64")
784+
785+
got := ExtractSessionID(headers, nil, nil)
786+
want := "amp:T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64"
787+
if got != want {
788+
t.Errorf("ExtractSessionID() with X-Amp-Thread-Id = %q, want %q", got, want)
789+
}
790+
}
791+
792+
// TestExtractSessionID_AmpThreadIdLowerPriority verifies X-Amp-Thread-Id is lower
793+
// priority than Claude Code metadata.user_id but higher than conversation_id.
794+
func TestExtractSessionID_AmpThreadIdPriority(t *testing.T) {
795+
t.Parallel()
796+
797+
// X-Amp-Thread-Id should be used when no Claude Code user_id is present
798+
headers := make(http.Header)
799+
headers.Set("X-Amp-Thread-Id", "T-priority-test")
800+
801+
payload := []byte(`{"conversation_id":"conv-12345"}`)
802+
got := ExtractSessionID(headers, payload, nil)
803+
want := "amp:T-priority-test"
804+
if got != want {
805+
t.Errorf("ExtractSessionID() = %q, want %q (Amp thread ID should take priority over conversation_id)", got, want)
806+
}
807+
808+
// Claude Code user_id should take priority over X-Amp-Thread-Id
809+
headers2 := make(http.Header)
810+
headers2.Set("X-Amp-Thread-Id", "T-priority-test")
811+
payload2 := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`)
812+
got2 := ExtractSessionID(headers2, payload2, nil)
813+
want2 := "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344"
814+
if got2 != want2 {
815+
t.Errorf("ExtractSessionID() = %q, want %q (Claude Code should take priority over Amp thread ID)", got2, want2)
816+
}
817+
}
818+
779819
// TestExtractSessionID_IdempotencyKey verifies that idempotency_key is intentionally
780820
// ignored for session affinity (it's auto-generated per-request, causing cache misses).
781821
func TestExtractSessionID_IdempotencyKey(t *testing.T) {

0 commit comments

Comments
 (0)