diff --git a/constant/context_key.go b/constant/context_key.go index 2ba2fe27489b..0963f05be84a 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -21,22 +21,23 @@ const ( ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" /* channel related keys */ - ContextKeyChannelId ContextKey = "channel_id" - ContextKeyChannelName ContextKey = "channel_name" - ContextKeyChannelCreateTime ContextKey = "channel_create_time" - ContextKeyChannelBaseUrl ContextKey = "base_url" - ContextKeyChannelType ContextKey = "channel_type" - ContextKeyChannelSetting ContextKey = "channel_setting" - ContextKeyChannelOtherSetting ContextKey = "channel_other_setting" - ContextKeyChannelParamOverride ContextKey = "param_override" - ContextKeyChannelHeaderOverride ContextKey = "header_override" - ContextKeyChannelOrganization ContextKey = "channel_organization" - ContextKeyChannelAutoBan ContextKey = "auto_ban" - ContextKeyChannelModelMapping ContextKey = "model_mapping" - ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping" - ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key" - ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" - ContextKeyChannelKey ContextKey = "channel_key" + ContextKeyChannelId ContextKey = "channel_id" + ContextKeyChannelName ContextKey = "channel_name" + ContextKeyChannelCreateTime ContextKey = "channel_create_time" + ContextKeyChannelBaseUrl ContextKey = "base_url" + ContextKeyChannelType ContextKey = "channel_type" + ContextKeyChannelSetting ContextKey = "channel_setting" + ContextKeyChannelOtherSetting ContextKey = "channel_other_setting" + ContextKeyChannelParamOverride ContextKey = "param_override" + ContextKeyChannelParamOverrideContext ContextKey = "param_override_context" + ContextKeyChannelHeaderOverride ContextKey = "header_override" + ContextKeyChannelOrganization ContextKey = "channel_organization" + ContextKeyChannelAutoBan ContextKey = "auto_ban" + ContextKeyChannelModelMapping ContextKey = "model_mapping" + ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping" + ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key" + ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" + ContextKeyChannelKey ContextKey = "channel_key" ContextKeyAutoGroup ContextKey = "auto_group" ContextKeyAutoGroupIndex ContextKey = "auto_group_index" diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index ef4d4fa04125..f870f08281db 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -101,6 +101,9 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo } // codex: store must be false request.Store = json.RawMessage("false") + // codex backend rejects OpenAI Responses metadata even though some clients + // include it on compatibility paths. + request.Metadata = nil // rm max_output_tokens request.MaxOutputTokens = nil request.Temperature = nil diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 8f69b9375612..e73bbc23145b 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -18,6 +18,26 @@ import ( "github.com/gin-gonic/gin" ) +func ensureRuntimeHeaderOverride(info *relaycommon.RelayInfo, headerName string, value string) { + if info == nil { + return + } + headerName = strings.TrimSpace(strings.ToLower(headerName)) + value = strings.TrimSpace(value) + if headerName == "" || value == "" { + return + } + + current := relaycommon.GetEffectiveHeaderOverride(info) + if existing := strings.TrimSpace(common.Interface2String(current[headerName])); existing != "" { + return + } + + current[headerName] = value + info.RuntimeHeadersOverride = current + info.UseRuntimeHeadersOverride = true +} + func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) { if info == nil || request == nil { return @@ -96,6 +116,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + ensureRuntimeHeaderOverride(info, "session_id", overriddenChatReq.PromptCacheKey) info.AppendRequestConversion(types.RelayFormatOpenAIResponses) savedRelayMode := info.RelayMode diff --git a/relay/common/override.go b/relay/common/override.go index af0b43616b0c..ae7b3c7ace34 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -1343,11 +1343,30 @@ func parseSyncTarget(spec string) (syncTarget, error) { kind: "header", key: key, }, nil + case "context": + return syncTarget{ + kind: "context", + key: key, + }, nil default: return syncTarget{}, fmt.Errorf("sync_fields target prefix is invalid: %s", raw) } } +func snapshotContextMap(context map[string]interface{}) map[string]interface{} { + if len(context) == 0 { + return map[string]interface{}{} + } + snapshot := make(map[string]interface{}, len(context)) + for key, value := range context { + if key == paramOverrideContextAuditRecorder { + continue + } + snapshot[key] = value + } + return snapshot +} + func readSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget) (interface{}, bool, error) { switch target.kind { case "json": @@ -1366,6 +1385,22 @@ func readSyncTargetValue(jsonStr string, context map[string]interface{}, target return nil, false, nil } return value, true, nil + case "context": + contextJSON, err := marshalContextJSON(snapshotContextMap(context)) + if err != nil { + return nil, false, err + } + if contextJSON == "" { + return nil, false, nil + } + value := gjson.Get(contextJSON, target.key) + if !value.Exists() || value.Type == gjson.Null { + return nil, false, nil + } + if value.Type == gjson.String && strings.TrimSpace(value.String()) == "" { + return nil, false, nil + } + return value.Value(), true, nil default: return nil, false, fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) } @@ -1385,6 +1420,33 @@ func writeSyncTargetValue(jsonStr string, context map[string]interface{}, target return "", err } return jsonStr, nil + case "context": + contextJSON, err := marshalContextJSON(snapshotContextMap(context)) + if err != nil { + return "", err + } + if contextJSON == "" { + contextJSON = "{}" + } + nextJSON, err := sjson.Set(contextJSON, target.key, value) + if err != nil { + return "", err + } + nextContext := make(map[string]interface{}) + if err := common.UnmarshalJsonStr(nextJSON, &nextContext); err != nil { + return "", err + } + recorder, _ := context[paramOverrideContextAuditRecorder] + for key := range context { + delete(context, key) + } + for key, item := range nextContext { + context[key] = item + } + if recorder != nil { + context[paramOverrideContextAuditRecorder] = recorder + } + return jsonStr, nil default: return "", fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) } @@ -2025,6 +2087,14 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { headerOverrideSource := GetEffectiveHeaderOverride(info) ctx[paramOverrideContextHeaderOverride] = sanitizeHeaderOverrideMap(headerOverrideSource) + if info.ChannelMeta != nil && len(info.ChannelMeta.ParamOverrideContext) > 0 { + for key, value := range info.ChannelMeta.ParamOverrideContext { + if _, exists := ctx[key]; exists { + continue + } + ctx[key] = value + } + } ctx["retry_index"] = info.RetryIndex ctx["is_retry"] = info.RetryIndex > 0 diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 1a7793ba2cc5..ef7788c896c2 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1506,6 +1506,43 @@ func TestApplyParamOverrideSyncFieldsJSONToHeader(t *testing.T) { } } +func TestApplyParamOverrideSyncFieldsContextToJSONAndHeader(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "context:channel_affinity.key", + "to": "json:prompt_cache_key", + }, + map[string]interface{}{ + "mode": "sync_fields", + "from": "context:channel_affinity.key", + "to": "header:session_id", + }, + }, + } + ctx := map[string]interface{}{ + "channel_affinity": map[string]interface{}{ + "key": "affinity-session", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"affinity-session"}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["session_id"] != "affinity-session" { + t.Fatalf("expected session_id to be synced from context, got: %v", headers["session_id"]) + } +} + func TestApplyParamOverrideSyncFieldsNoChangeWhenBothExist(t *testing.T) { input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-body"}`) override := map[string]interface{}{ diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index e4421fc11749..873be1903581 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -69,6 +69,7 @@ type ChannelMeta struct { Organization string ChannelCreateTime int64 ParamOverride map[string]interface{} + ParamOverrideContext map[string]interface{} HeadersOverride map[string]interface{} ChannelSetting dto.ChannelSettings ChannelOtherSettings dto.ChannelOtherSettings @@ -190,6 +191,7 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) { Organization: c.GetString("channel_organization"), ChannelCreateTime: c.GetInt64("channel_create_time"), ParamOverride: paramOverride, + ParamOverrideContext: common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverrideContext), HeadersOverride: headerOverride, UpstreamModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), IsModelMapped: false, diff --git a/service/channel_affinity.go b/service/channel_affinity.go index 9f89585fac03..2806d1331451 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -10,6 +10,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/pkg/cachex" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -41,19 +42,21 @@ var ( ) type channelAffinityMeta struct { - CacheKey string - TTLSeconds int - RuleName string - SkipRetry bool - ParamTemplate map[string]interface{} - KeySourceType string - KeySourceKey string - KeySourcePath string - KeyHint string - KeyFingerprint string - UsingGroup string - ModelName string - RequestPath string + CacheKey string + TTLSeconds int + RuleName string + SkipRetry bool + ParamTemplate map[string]interface{} + KeyValue string + KeySourceType string + KeySourceKey string + KeySourcePath string + KeySourceNestedPath string + KeyHint string + KeyFingerprint string + UsingGroup string + ModelName string + RequestPath string } type ChannelAffinityStatsContext struct { @@ -276,7 +279,26 @@ func matchAnyIncludeFold(patterns []string, s string) bool { return false } +func extractNestedChannelAffinityValue(rawValue string, nestedPath string) string { + rawValue = strings.TrimSpace(rawValue) + nestedPath = strings.TrimSpace(nestedPath) + if rawValue == "" || nestedPath == "" || !gjson.Valid(rawValue) { + return "" + } + res := gjson.Get(rawValue, nestedPath) + if !res.Exists() { + return "" + } + switch res.Type { + case gjson.String, gjson.Number, gjson.True, gjson.False: + return strings.TrimSpace(res.String()) + default: + return strings.TrimSpace(res.Raw) + } +} + func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAffinityKeySource) string { + nestedPath := strings.TrimSpace(src.NestedPath) switch src.Type { case "context_int": if src.Key == "" { @@ -286,12 +308,29 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf if v <= 0 { return "" } - return strconv.Itoa(v) + value := strconv.Itoa(v) + if nestedPath != "" { + return extractNestedChannelAffinityValue(value, nestedPath) + } + return value case "context_string": if src.Key == "" { return "" } - return strings.TrimSpace(c.GetString(src.Key)) + value := strings.TrimSpace(c.GetString(src.Key)) + if nestedPath != "" { + return extractNestedChannelAffinityValue(value, nestedPath) + } + return value + case "request_header": + if c == nil || c.Request == nil || strings.TrimSpace(src.Key) == "" { + return "" + } + value := strings.TrimSpace(c.Request.Header.Get(src.Key)) + if nestedPath != "" { + return extractNestedChannelAffinityValue(value, nestedPath) + } + return value case "gjson": if src.Path == "" { return "" @@ -308,6 +347,16 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf if !res.Exists() { return "" } + if nestedPath != "" { + switch res.Type { + case gjson.String: + return extractNestedChannelAffinityValue(res.String(), nestedPath) + case gjson.JSON: + return extractNestedChannelAffinityValue(res.Raw, nestedPath) + default: + return "" + } + } switch res.Type { case gjson.String, gjson.Number, gjson.True, gjson.False: return strings.TrimSpace(res.String()) @@ -335,6 +384,9 @@ func setChannelAffinityContext(c *gin.Context, meta channelAffinityMeta) { c.Set(ginKeyChannelAffinityCacheKey, meta.CacheKey) c.Set(ginKeyChannelAffinityTTLSeconds, meta.TTLSeconds) c.Set(ginKeyChannelAffinityMeta, meta) + if overrideCtx := buildChannelAffinityParamOverrideContext(meta); len(overrideCtx) > 0 { + common.SetContextKey(c, constant.ContextKeyChannelParamOverrideContext, overrideCtx) + } } func getChannelAffinityContext(c *gin.Context) (string, int, bool) { @@ -505,12 +557,34 @@ func appendChannelAffinityTemplateAdminInfo(c *gin.Context, meta channelAffinity "key_source": meta.KeySourceType, "key_key": meta.KeySourceKey, "key_path": meta.KeySourcePath, + "key_nested_path": meta.KeySourceNestedPath, "key_hint": meta.KeyHint, "key_fp": meta.KeyFingerprint, "override_template": templateInfo, }) } +func buildChannelAffinityParamOverrideContext(meta channelAffinityMeta) map[string]interface{} { + if strings.TrimSpace(meta.KeyValue) == "" { + return nil + } + return map[string]interface{}{ + "channel_affinity": map[string]interface{}{ + "key": meta.KeyValue, + "rule_name": meta.RuleName, + "key_source_type": meta.KeySourceType, + "key_source_key": meta.KeySourceKey, + "key_source_path": meta.KeySourcePath, + "key_source_nested_path": meta.KeySourceNestedPath, + "key_hint": meta.KeyHint, + "key_fingerprint": meta.KeyFingerprint, + "using_group": meta.UsingGroup, + "model": meta.ModelName, + "request_path": meta.RequestPath, + }, + } +} + // ApplyChannelAffinityOverrideTemplate merges per-rule channel override templates onto the selected channel override config. func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[string]interface{}) (map[string]interface{}, bool) { if c == nil { @@ -576,19 +650,21 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue) cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix setChannelAffinityContext(c, channelAffinityMeta{ - CacheKey: cacheKeyFull, - TTLSeconds: ttlSeconds, - RuleName: rule.Name, - SkipRetry: rule.SkipRetryOnFailure, - ParamTemplate: cloneStringAnyMap(rule.ParamOverrideTemplate), - KeySourceType: strings.TrimSpace(usedSource.Type), - KeySourceKey: strings.TrimSpace(usedSource.Key), - KeySourcePath: strings.TrimSpace(usedSource.Path), - KeyHint: buildChannelAffinityKeyHint(affinityValue), - KeyFingerprint: affinityFingerprint(affinityValue), - UsingGroup: usingGroup, - ModelName: modelName, - RequestPath: path, + CacheKey: cacheKeyFull, + TTLSeconds: ttlSeconds, + RuleName: rule.Name, + SkipRetry: rule.SkipRetryOnFailure, + ParamTemplate: cloneStringAnyMap(rule.ParamOverrideTemplate), + KeyValue: affinityValue, + KeySourceType: strings.TrimSpace(usedSource.Type), + KeySourceKey: strings.TrimSpace(usedSource.Key), + KeySourcePath: strings.TrimSpace(usedSource.Path), + KeySourceNestedPath: strings.TrimSpace(usedSource.NestedPath), + KeyHint: buildChannelAffinityKeyHint(affinityValue), + KeyFingerprint: affinityFingerprint(affinityValue), + UsingGroup: usingGroup, + ModelName: modelName, + RequestPath: path, }) cache := getChannelAffinityCache() diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go index 264f91226cbd..1b83734618b5 100644 --- a/service/channel_affinity_template_test.go +++ b/service/channel_affinity_template_test.go @@ -245,3 +245,81 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) { _, exists = info.RuntimeHeadersOverride["x-codex-turn-metadata"] require.False(t, exists) } + +func TestClaudeTemplateSyncsClaudeSessionToPromptCacheKeyAndSessionHeader(t *testing.T) { + setting := operation_setting.GetChannelAffinitySetting() + require.NotNil(t, setting) + + var claudeRule *operation_setting.ChannelAffinityRule + for i := range setting.Rules { + rule := &setting.Rules[i] + if strings.EqualFold(strings.TrimSpace(rule.Name), "claude cli trace") { + claudeRule = rule + break + } + } + require.NotNil(t, claudeRule) + + meta := channelAffinityMeta{ + RuleName: claudeRule.Name, + ParamTemplate: claudeRule.ParamOverrideTemplate, + KeyValue: "claude-session-123", + } + ctx := buildChannelAffinityTemplateContextForTest(meta) + + mergedOverride, applied := ApplyChannelAffinityOverrideTemplate(ctx, map[string]interface{}{}) + require.True(t, applied) + + info := &relaycommon.RelayInfo{ + RequestHeaders: map[string]string{ + "X-Claude-Code-Session-Id": "claude-session-123", + "User-Agent": "claude-cli-test", + "X-App": "cli", + }, + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: mergedOverride, + ParamOverrideContext: buildChannelAffinityParamOverrideContext(meta), + }, + } + + out, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-5.4"}`), info) + require.NoError(t, err) + require.JSONEq(t, `{"model":"gpt-5.4","prompt_cache_key":"claude-session-123"}`, string(out)) + + require.True(t, info.UseRuntimeHeadersOverride) + require.Equal(t, "claude-session-123", info.RuntimeHeadersOverride["x-claude-code-session-id"]) + require.Equal(t, "claude-session-123", info.RuntimeHeadersOverride["session_id"]) + require.Equal(t, "claude-cli-test", info.RuntimeHeadersOverride["user-agent"]) + require.Equal(t, "cli", info.RuntimeHeadersOverride["x-app"]) +} + +func TestExtractChannelAffinityValueFromRequestHeader(t *testing.T) { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + ctx.Request.Header.Set("X-Claude-Code-Session-Id", "header-session") + + value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{ + Type: "request_header", + Key: "X-Claude-Code-Session-Id", + }) + require.Equal(t, "header-session", value) +} + +func TestExtractChannelAffinityValueFromNestedJSONString(t *testing.T) { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/v1/messages", + strings.NewReader(`{"metadata":{"user_id":"{\"device_id\":\"dev-1\",\"session_id\":\"nested-session\"}"}}`), + ) + ctx.Request.Header.Set("Content-Type", "application/json") + + value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{ + Type: "gjson", + Path: "metadata.user_id", + NestedPath: "session_id", + }) + require.Equal(t, "nested-session", value) +} diff --git a/service/convert.go b/service/convert.go index 59d4f8fedc46..37359a2c933f 100644 --- a/service/convert.go +++ b/service/convert.go @@ -18,6 +18,7 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re openAIRequest := dto.GeneralOpenAIRequest{ Model: claudeRequest.Model, Temperature: claudeRequest.Temperature, + Metadata: claudeRequest.Metadata, } if claudeRequest.MaxTokens != nil { openAIRequest.MaxTokens = lo.ToPtr(lo.FromPtr(claudeRequest.MaxTokens)) @@ -32,7 +33,7 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re openAIRequest.Stream = lo.ToPtr(lo.FromPtr(claudeRequest.Stream)) } - isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter + isOpenRouter := info != nil && info.ChannelType == constant.ChannelTypeOpenRouter if isOpenRouter { if effort := claudeRequest.GetEfforts(); effort != "" { @@ -57,7 +58,7 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re } openAIRequest.Reasoning = reasoningJSON } - } else { + } else if info != nil { thinkingSuffix := "-thinking" if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go index 16096b88f597..8914d3954345 100644 --- a/service/openaicompat/chat_to_responses.go +++ b/service/openaicompat/chat_to_responses.go @@ -355,6 +355,11 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls) } + var promptCacheKeyRaw json.RawMessage + if strings.TrimSpace(req.PromptCacheKey) != "" { + promptCacheKeyRaw, _ = common.Marshal(strings.TrimSpace(req.PromptCacheKey)) + } + textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat) maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0)) @@ -373,19 +378,21 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d } out := &dto.OpenAIResponsesRequest{ - Model: req.Model, - Input: inputRaw, - Instructions: instructionsRaw, - Stream: req.Stream, - Temperature: req.Temperature, - Text: textRaw, - ToolChoice: toolChoiceRaw, - Tools: toolsRaw, - TopP: topP, - User: req.User, - ParallelToolCalls: parallelToolCallsRaw, - Store: req.Store, - Metadata: req.Metadata, + Model: req.Model, + Input: inputRaw, + Instructions: instructionsRaw, + Stream: req.Stream, + Temperature: req.Temperature, + Text: textRaw, + ToolChoice: toolChoiceRaw, + Tools: toolsRaw, + TopP: topP, + User: req.User, + ParallelToolCalls: parallelToolCallsRaw, + Store: req.Store, + Metadata: req.Metadata, + PromptCacheKey: promptCacheKeyRaw, + PromptCacheRetention: req.PromptCacheRetention, } if req.MaxTokens != nil || req.MaxCompletionTokens != nil { out.MaxOutputTokens = lo.ToPtr(maxOutputTokens) diff --git a/service/openaicompat/chat_to_responses_test.go b/service/openaicompat/chat_to_responses_test.go new file mode 100644 index 000000000000..7fb216c96b4b --- /dev/null +++ b/service/openaicompat/chat_to_responses_test.go @@ -0,0 +1,32 @@ +package openaicompat + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/stretchr/testify/require" +) + +func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) { + req := &dto.GeneralOpenAIRequest{ + Model: "gpt-5.4", + PromptCacheKey: "sess-123", + Metadata: []byte(`{"trace":"abc"}`), + Messages: []dto.Message{ + { + Role: "user", + Content: "hello", + }, + }, + } + + out, err := ChatCompletionsRequestToResponsesRequest(req) + require.NoError(t, err) + require.JSONEq(t, `"sess-123"`, string(out.PromptCacheKey)) + require.JSONEq(t, `{"trace":"abc"}`, string(out.Metadata)) + + var input []map[string]any + require.NoError(t, common.Unmarshal(out.Input, &input)) + require.Len(t, input, 1) +} diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index 74213e994104..6857a0fe5556 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -3,9 +3,10 @@ package operation_setting import "github.com/QuantumNous/new-api/setting/config" type ChannelAffinityKeySource struct { - Type string `json:"type"` // context_int, context_string, gjson - Key string `json:"key,omitempty"` - Path string `json:"path,omitempty"` + Type string `json:"type"` // context_int, context_string, request_header, gjson + Key string `json:"key,omitempty"` + Path string `json:"path,omitempty"` + NestedPath string `json:"nested_path,omitempty"` } type ChannelAffinityRule struct { @@ -53,6 +54,7 @@ var claudeCliPassThroughHeaders = []string{ "X-Stainless-Timeout", "User-Agent", "X-App", + "X-Claude-Code-Session-Id", "Anthropic-Beta", "Anthropic-Dangerous-Direct-Browser-Access", "Anthropic-Version", @@ -72,6 +74,30 @@ func buildPassHeaderTemplate(headers []string) map[string]interface{} { } } +func buildClaudeCliHeaderTemplate(headers []string) map[string]interface{} { + clonedHeaders := make([]string, 0, len(headers)) + clonedHeaders = append(clonedHeaders, headers...) + return map[string]interface{}{ + "operations": []map[string]interface{}{ + { + "mode": "pass_headers", + "value": clonedHeaders, + "keep_origin": true, + }, + { + "mode": "sync_fields", + "from": "context:channel_affinity.key", + "to": "header:session_id", + }, + { + "mode": "sync_fields", + "from": "context:channel_affinity.key", + "to": "json:prompt_cache_key", + }, + }, + } +} + var channelAffinitySetting = ChannelAffinitySetting{ Enabled: true, SwitchOnSuccess: true, @@ -95,14 +121,15 @@ var channelAffinitySetting = ChannelAffinitySetting{ }, { Name: "claude cli trace", - ModelRegex: []string{"^claude-.*$"}, + ModelRegex: []string{"^claude-.*$", "^gpt-.*$"}, PathRegex: []string{"/v1/messages"}, KeySources: []ChannelAffinityKeySource{ - {Type: "gjson", Path: "metadata.user_id"}, + {Type: "request_header", Key: "X-Claude-Code-Session-Id"}, + {Type: "gjson", Path: "metadata.user_id", NestedPath: "session_id"}, }, ValueRegex: "", TTLSeconds: 0, - ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders), + ParamOverrideTemplate: buildClaudeCliHeaderTemplate(claudeCliPassThroughHeaders), SkipRetryOnFailure: true, IncludeUsingGroup: true, IncludeRuleName: true, diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 5293fae768aa..6e999f2e6e11 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -273,6 +273,7 @@ const getModeValuePlaceholder = (mode) => { const SYNC_TARGET_TYPE_OPTIONS = [ { label: '请求体字段', value: 'json' }, { label: '请求头字段', value: 'header' }, + { label: '上下文字段', value: 'context' }, ]; const LEGACY_TEMPLATE = { @@ -770,11 +771,15 @@ const parseSyncTargetSpec = (spec) => { if (prefix === 'header') { return { type: 'header', key }; } + if (prefix === 'context') { + return { type: 'context', key }; + } return { type: 'json', key }; }; const buildSyncTargetSpec = (type, key) => { - const normalizedType = type === 'header' ? 'header' : 'json'; + const normalizedType = + type === 'header' || type === 'context' ? type : 'json'; const normalizedKey = String(key ?? '').trim(); if (!normalizedKey) return ''; return `${normalizedType}:${normalizedKey}`; @@ -2989,14 +2994,12 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { className='cursor-pointer' onClick={() => updateOperation(selectedOperation.id, { - from: 'header:session_id', + from: 'context:channel_affinity.key', to: 'json:prompt_cache_key', }) } > - { - 'header:session_id -> json:prompt_cache_key' - } + {'context:channel_affinity.key -> json:prompt_cache_key'} { className='cursor-pointer' onClick={() => updateOperation(selectedOperation.id, { - from: 'json:prompt_cache_key', + from: 'context:channel_affinity.key', to: 'header:session_id', }) } > - { - 'json:prompt_cache_key -> header:session_id' - } + {'context:channel_affinity.key -> header:session_id'} diff --git a/web/src/constants/channel-affinity-template.constants.js b/web/src/constants/channel-affinity-template.constants.js index f3e88c2639ce..73f8ea9fe83b 100644 --- a/web/src/constants/channel-affinity-template.constants.js +++ b/web/src/constants/channel-affinity-template.constants.js @@ -46,19 +46,36 @@ export const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [ 'X-Stainless-Timeout', 'User-Agent', 'X-App', + 'X-Claude-Code-Session-Id', 'Anthropic-Beta', 'Anthropic-Dangerous-Direct-Browser-Access', 'Anthropic-Version', ]; +export const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = { + operations: [ + { + mode: 'pass_headers', + value: [...CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS], + keep_origin: true, + }, + { + mode: 'sync_fields', + from: 'context:channel_affinity.key', + to: 'header:session_id', + }, + { + mode: 'sync_fields', + from: 'context:channel_affinity.key', + to: 'json:prompt_cache_key', + }, + ], +}; + export const CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate( CODEX_CLI_HEADER_PASSTHROUGH_HEADERS, ); -export const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate( - CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS, -); - export const CHANNEL_AFFINITY_RULE_TEMPLATES = { codexCli: { name: 'codex cli trace', @@ -74,9 +91,12 @@ export const CHANNEL_AFFINITY_RULE_TEMPLATES = { }, claudeCli: { name: 'claude cli trace', - model_regex: ['^claude-.*$'], + model_regex: ['^claude-.*$', '^gpt-.*$'], path_regex: ['/v1/messages'], - key_sources: [{ type: 'gjson', path: 'metadata.user_id' }], + key_sources: [ + { type: 'request_header', key: 'X-Claude-Code-Session-Id' }, + { type: 'gjson', path: 'metadata.user_id', nested_path: 'session_id' }, + ], param_override_template: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE, value_regex: '', ttl_seconds: 0, diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx index c179e855e38a..9ccfa5b72048 100644 --- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx +++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx @@ -69,6 +69,7 @@ const KEY_RULES = 'channel_affinity_setting.rules'; const KEY_SOURCE_TYPES = [ { label: 'context_int', value: 'context_int' }, { label: 'context_string', value: 'context_string' }, + { label: 'request_header', value: 'request_header' }, { label: 'gjson', value: 'gjson' }, ]; @@ -91,7 +92,9 @@ const RULES_JSON_PLACEHOLDER = `[ "path_regex": ["/v1/chat/completions"], "user_agent_include": ["curl", "PostmanRuntime"], "key_sources": [ + { "type": "request_header", "key": "X-Session-Id" }, { "type": "gjson", "path": "metadata.conversation_id" }, + { "type": "gjson", "path": "metadata.user_id", "nested_path": "session_id" }, { "type": "context_string", "key": "conversation_id" } ], "value_regex": "^[-0-9A-Za-z._:]{1,128}$", @@ -143,12 +146,13 @@ const normalizeKeySource = (src) => { const type = (src?.type || '').trim(); const key = (src?.key || '').trim(); const path = (src?.path || '').trim(); + const nestedPath = (src?.nested_path || src?.nestedPath || '').trim(); if (type === 'gjson') { - return { type, key: '', path }; + return { type, key: '', path, nested_path: nestedPath }; } - return { type, key, path: '' }; + return { type, key, path: '', nested_path: nestedPath }; }; const makeUniqueName = (existingNames, baseName) => { @@ -525,7 +529,12 @@ export default function SettingsChannelAffinity(props) { if (xs.length === 0) return '-'; return xs.slice(0, 3).map((src, idx) => { const s = normalizeKeySource(src); - const detail = s.type === 'gjson' ? s.path : s.key; + const detail = + s.type === 'gjson' + ? s.nested_path + ? `${s.path} -> ${s.nested_path}` + : s.path + : s.key; return ( {s.type}:{detail} @@ -628,7 +637,11 @@ export default function SettingsChannelAffinity(props) { const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type); if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' }; for (const x of xs) { - if (x.type === 'context_int' || x.type === 'context_string') { + if ( + x.type === 'context_int' || + x.type === 'context_string' || + x.type === 'request_header' + ) { if (!x.key) return { ok: false, message: 'Key 不能为空' }; } else if (x.type === 'gjson') { if (!x.path) return { ok: false, message: 'Path 不能为空' }; @@ -1284,7 +1297,7 @@ export default function SettingsChannelAffinity(props) { {t( - 'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。', + 'context_int/context_string 从请求上下文读取;request_header 从入口请求头读取;gjson 从入口请求的 JSON body 按 gjson path 读取,可选继续用内层 JSON Path 提取子字段。', )}
@@ -1326,7 +1339,11 @@ export default function SettingsChannelAffinity(props) { return ( { + const src = normalizeKeySource( + editingRule?.key_sources?.[idx], + ); + return ( + + updateKeySource(idx, { nested_path: value }) + } + /> + ); + }, + }, { title: t('操作'), width: 90,