Skip to content

Commit 68830e6

Browse files
authored
feat: support request_header key source (#4903)
* feat: support request_header key source in backend and settings UI * feat: support request_header channel affinity source
1 parent 2d968c3 commit 68830e6

6 files changed

Lines changed: 81 additions & 6 deletions

File tree

service/channel_affinity.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
302302
return ""
303303
}
304304
return strings.TrimSpace(c.GetString(src.Key))
305+
case "request_header":
306+
if c == nil || c.Request == nil || src.Key == "" {
307+
return ""
308+
}
309+
return strings.TrimSpace(c.Request.Header.Get(src.Key))
305310
case "gjson":
306311
if src.Path == "" {
307312
return ""

service/channel_affinity_template_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,66 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
176176
}
177177
}
178178

179+
func TestExtractChannelAffinityValue_RequestHeader(t *testing.T) {
180+
rec := httptest.NewRecorder()
181+
ctx, _ := gin.CreateTestContext(rec)
182+
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
183+
ctx.Request.Header.Set("X-Affinity-Key", " tenant-123 ")
184+
185+
value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{
186+
Type: "request_header",
187+
Key: "X-Affinity-Key",
188+
})
189+
190+
require.Equal(t, "tenant-123", value)
191+
}
192+
193+
func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
194+
gin.SetMode(gin.TestMode)
195+
196+
rule := operation_setting.ChannelAffinityRule{
197+
Name: "header-affinity",
198+
ModelRegex: []string{"^gpt-.*$"},
199+
PathRegex: []string{"/v1/responses"},
200+
KeySources: []operation_setting.ChannelAffinityKeySource{
201+
{Type: "request_header", Key: "X-Affinity-Key"},
202+
},
203+
IncludeRuleName: true,
204+
IncludeModelName: true,
205+
}
206+
207+
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
208+
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
209+
210+
cache := getChannelAffinityCache()
211+
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
212+
t.Cleanup(func() {
213+
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
214+
})
215+
216+
setting := operation_setting.GetChannelAffinitySetting()
217+
originalRules := setting.Rules
218+
setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...)
219+
t.Cleanup(func() {
220+
setting.Rules = originalRules
221+
})
222+
223+
rec := httptest.NewRecorder()
224+
ctx, _ := gin.CreateTestContext(rec)
225+
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
226+
ctx.Request.Header.Set("X-Affinity-Key", affinityValue)
227+
228+
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
229+
require.True(t, found)
230+
require.Equal(t, 9528, channelID)
231+
232+
meta, ok := getChannelAffinityMeta(ctx)
233+
require.True(t, ok)
234+
require.Equal(t, "request_header", meta.KeySourceType)
235+
require.Equal(t, "X-Affinity-Key", meta.KeySourceKey)
236+
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
237+
}
238+
179239
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
180240
gin.SetMode(gin.TestMode)
181241

setting/operation_setting/channel_affinity_setting.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package operation_setting
33
import "github.com/QuantumNous/new-api/setting/config"
44

55
type ChannelAffinityKeySource struct {
6-
Type string `json:"type"` // context_int, context_string, gjson
6+
Type string `json:"type"` // context_int, context_string, request_header, gjson
77
Key string `json:"key,omitempty"`
88
Path string `json:"path,omitempty"`
99
}

web/classic/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const KEY_RULES = 'channel_affinity_setting.rules';
6969
const KEY_SOURCE_TYPES = [
7070
{ label: 'context_int', value: 'context_int' },
7171
{ label: 'context_string', value: 'context_string' },
72+
{ label: 'request_header', value: 'request_header' },
7273
{ label: 'gjson', value: 'gjson' },
7374
];
7475

@@ -659,7 +660,11 @@ export default function SettingsChannelAffinity(props) {
659660
const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
660661
if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
661662
for (const x of xs) {
662-
if (x.type === 'context_int' || x.type === 'context_string') {
663+
if (
664+
x.type === 'context_int' ||
665+
x.type === 'context_string' ||
666+
x.type === 'request_header'
667+
) {
663668
if (!x.key) return { ok: false, message: 'Key 不能为空' };
664669
} else if (x.type === 'gjson') {
665670
if (!x.path) return { ok: false, message: 'Path 不能为空' };
@@ -1316,7 +1321,7 @@ export default function SettingsChannelAffinity(props) {
13161321
</Space>
13171322
<Text type='tertiary' size='small'>
13181323
{t(
1319-
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
1324+
'context_int/context_string 从请求上下文读取;request_header 从用户请求头读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
13201325
)}
13211326
</Text>
13221327
<div style={{ marginTop: 8, marginBottom: 8 }}>
@@ -1358,7 +1363,7 @@ export default function SettingsChannelAffinity(props) {
13581363
return (
13591364
<Input
13601365
placeholder={
1361-
isGjson ? 'metadata.conversation_id' : 'user_id'
1366+
isGjson ? 'metadata.conversation_id' : 'X-Affinity-Key'
13621367
}
13631368
aria-label={t('Key 或 Path')}
13641369
value={isGjson ? src.path : src.key}

web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ import { Textarea } from '@/components/ui/textarea'
5050
import { RULE_TEMPLATES } from './constants'
5151
import type { AffinityRule, KeySource } from './types'
5252

53-
const KEY_SOURCE_TYPES = ['context_int', 'context_string', 'gjson'] as const
53+
const KEY_SOURCE_TYPES = [
54+
'context_int',
55+
'context_string',
56+
'request_header',
57+
'gjson',
58+
] as const
5459

5560
const CONTEXT_KEY_PRESETS = [
5661
'id',

web/default/src/features/system-settings/general/channel-affinity/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
For commercial licensing, please contact support@quantumnous.com
1818
*/
1919
export interface KeySource {
20-
type: 'context_int' | 'context_string' | 'gjson'
20+
type: 'context_int' | 'context_string' | 'request_header' | 'gjson'
2121
key?: string
2222
path?: string
2323
}

0 commit comments

Comments
 (0)