mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 11:08:37 +00:00
Merge pull request #3182 from seefs001/feature/params-override-beta-header-append
feat:support $keep_only_declared and deduped $append for header override
This commit is contained in:
@@ -847,24 +847,30 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
|
|||||||
return "", false, fmt.Errorf("header value mapping cannot be empty")
|
return "", false, fmt.Errorf("header value mapping cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceValue, exists := getHeaderValueFromContext(context, headerName)
|
appendTokens, err := parseHeaderAppendTokens(mapping)
|
||||||
if !exists {
|
if err != nil {
|
||||||
return "", false, nil
|
return "", false, err
|
||||||
}
|
}
|
||||||
sourceTokens := splitHeaderListValue(sourceValue)
|
keepOnlyDeclared := parseHeaderKeepOnlyDeclared(mapping)
|
||||||
if len(sourceTokens) == 0 {
|
|
||||||
return "", false, nil
|
sourceValue, exists := getHeaderValueFromContext(context, headerName)
|
||||||
|
sourceTokens := make([]string, 0)
|
||||||
|
if exists {
|
||||||
|
sourceTokens = splitHeaderListValue(sourceValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
wildcardValue, hasWildcard := mapping["*"]
|
wildcardValue, hasWildcard := mapping["*"]
|
||||||
resultTokens := make([]string, 0, len(sourceTokens))
|
resultTokens := make([]string, 0, len(sourceTokens)+len(appendTokens))
|
||||||
for _, token := range sourceTokens {
|
for _, token := range sourceTokens {
|
||||||
replacementRaw, hasReplacement := mapping[token]
|
replacementRaw, hasReplacement := mapping[token]
|
||||||
if !hasReplacement && hasWildcard {
|
if !hasReplacement && hasWildcard && !keepOnlyDeclared {
|
||||||
replacementRaw = wildcardValue
|
replacementRaw = wildcardValue
|
||||||
hasReplacement = true
|
hasReplacement = true
|
||||||
}
|
}
|
||||||
if !hasReplacement {
|
if !hasReplacement {
|
||||||
|
if keepOnlyDeclared {
|
||||||
|
continue
|
||||||
|
}
|
||||||
resultTokens = append(resultTokens, token)
|
resultTokens = append(resultTokens, token)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -875,6 +881,7 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
|
|||||||
resultTokens = append(resultTokens, replacementTokens...)
|
resultTokens = append(resultTokens, replacementTokens...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultTokens = append(resultTokens, appendTokens...)
|
||||||
resultTokens = lo.Uniq(resultTokens)
|
resultTokens = lo.Uniq(resultTokens)
|
||||||
if len(resultTokens) == 0 {
|
if len(resultTokens) == 0 {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
@@ -882,6 +889,26 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
|
|||||||
return strings.Join(resultTokens, ","), true, nil
|
return strings.Join(resultTokens, ","), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseHeaderAppendTokens(mapping map[string]interface{}) ([]string, error) {
|
||||||
|
appendRaw, ok := mapping["$append"]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseHeaderReplacementTokens(appendRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeaderKeepOnlyDeclared(mapping map[string]interface{}) bool {
|
||||||
|
keepOnlyDeclaredRaw, ok := mapping["$keep_only_declared"]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keepOnlyDeclared, ok := keepOnlyDeclaredRaw.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return keepOnlyDeclared
|
||||||
|
}
|
||||||
|
|
||||||
func parseHeaderReplacementTokens(value interface{}) ([]string, error) {
|
func parseHeaderReplacementTokens(value interface{}) ([]string, error) {
|
||||||
switch raw := value.(type) {
|
switch raw := value.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
|
|||||||
@@ -1653,6 +1653,141 @@ func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapAppendsTokens(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"header_override": map[string]interface{}{
|
||||||
|
"anthropic-beta": "computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" {
|
||||||
|
t.Fatalf("expected anthropic-beta to append new token without duplicates, got: %v", headers["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapAppendsTokensWhenHeaderMissing(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := map[string]interface{}{}
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["anthropic-beta"] != "context-1m-2025-08-07,computer-use-2025-01-24" {
|
||||||
|
t.Fatalf("expected anthropic-beta to be created from appended tokens, got: %v", headers["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDropsUndeclaredTokens(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"computer-use-2025-01-24": "computer-use-2025-01-24",
|
||||||
|
"$append": []interface{}{"context-1m-2025-08-07"},
|
||||||
|
"$keep_only_declared": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"header_override": map[string]interface{}{
|
||||||
|
"anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" {
|
||||||
|
t.Fatalf("expected anthropic-beta to keep only declared tokens, got: %v", headers["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDeletesHeaderWhenNothingDeclaredMatches(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"computer-use-2025-01-24": "computer-use-2025-01-24",
|
||||||
|
"$keep_only_declared": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"header_override": map[string]interface{}{
|
||||||
|
"anthropic-beta": "advanced-tool-use-2025-11-20",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if _, exists := headers["anthropic-beta"]; exists {
|
||||||
|
t.Fatalf("expected anthropic-beta to be deleted when no declared tokens remain, got: %v", headers["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) {
|
func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) {
|
||||||
input := []byte(`{"temperature":0.7}`)
|
input := []byte(`{"temperature":0.7}`)
|
||||||
override := map[string]interface{}{
|
override := map[string]interface{}{
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const MODE_DESCRIPTIONS = {
|
|||||||
prune_objects: '按条件清理对象中的子项',
|
prune_objects: '按条件清理对象中的子项',
|
||||||
pass_headers: '把指定请求头透传到上游请求',
|
pass_headers: '把指定请求头透传到上游请求',
|
||||||
sync_fields: '在一个字段有值、另一个缺失时自动补齐',
|
sync_fields: '在一个字段有值、另一个缺失时自动补齐',
|
||||||
set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除)',
|
set_header: '设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做删除、替换、追加或白名单保留',
|
||||||
delete_header: '删除运行期请求头',
|
delete_header: '删除运行期请求头',
|
||||||
copy_header: '复制请求头',
|
copy_header: '复制请求头',
|
||||||
move_header: '移动请求头',
|
move_header: '移动请求头',
|
||||||
@@ -230,17 +230,29 @@ const getModeValueLabel = (mode) => {
|
|||||||
return '值(支持 JSON 或普通文本)';
|
return '值(支持 JSON 或普通文本)';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HEADER_VALUE_JSONC_EXAMPLE = `{
|
||||||
|
// 置空:删除 Bedrock 不支持的 beta特性
|
||||||
|
"files-api-2025-04-14": null,
|
||||||
|
|
||||||
|
// 替换:把旧特性改成兼容特性
|
||||||
|
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
|
||||||
|
|
||||||
|
// 追加:在末尾补一个需要的特性
|
||||||
|
"$append": ["context-1m-2025-08-07"]
|
||||||
|
}`;
|
||||||
|
|
||||||
const getModeValuePlaceholder = (mode) => {
|
const getModeValuePlaceholder = (mode) => {
|
||||||
if (mode === 'set_header') {
|
if (mode === 'set_header') {
|
||||||
return [
|
return [
|
||||||
'String example:',
|
'纯字符串(整条覆盖):',
|
||||||
'Bearer sk-xxx',
|
'Bearer sk-xxx',
|
||||||
'',
|
'',
|
||||||
'JSON map example:',
|
'或使用 JSON 规则:',
|
||||||
'{"advanced-tool-use-2025-11-20": null, "computer-use-2025-01-24": "computer-use-2025-01-24"}',
|
'{',
|
||||||
'',
|
' "files-api-2025-04-14": null,',
|
||||||
'JSON map wildcard:',
|
' "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",',
|
||||||
'{"*": null, "computer-use-2025-11-24": "computer-use-2025-11-24"}',
|
' "$append": ["context-1m-2025-08-07"]',
|
||||||
|
'}',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
if (mode === 'pass_headers') return 'Authorization, X-Request-Id';
|
if (mode === 'pass_headers') return 'Authorization, X-Request-Id';
|
||||||
@@ -258,11 +270,6 @@ const getModeValuePlaceholder = (mode) => {
|
|||||||
return '0.7';
|
return '0.7';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getModeValueHelp = (mode) => {
|
|
||||||
if (mode !== 'set_header') return '';
|
|
||||||
return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则。';
|
|
||||||
};
|
|
||||||
|
|
||||||
const SYNC_TARGET_TYPE_OPTIONS = [
|
const SYNC_TARGET_TYPE_OPTIONS = [
|
||||||
{ label: '请求体字段', value: 'json' },
|
{ label: '请求体字段', value: 'json' },
|
||||||
{ label: '请求头字段', value: 'header' },
|
{ label: '请求头字段', value: 'header' },
|
||||||
@@ -1075,6 +1082,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
const [dragOverPosition, setDragOverPosition] = useState('before');
|
const [dragOverPosition, setDragOverPosition] = useState('before');
|
||||||
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
|
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
|
||||||
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
|
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
|
||||||
|
const [headerValueExampleVisible, setHeaderValueExampleVisible] = useState(false);
|
||||||
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
|
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
|
||||||
const [fieldGuideTarget, setFieldGuideTarget] = useState('path');
|
const [fieldGuideTarget, setFieldGuideTarget] = useState('path');
|
||||||
const [fieldGuideKeyword, setFieldGuideKeyword] = useState('');
|
const [fieldGuideKeyword, setFieldGuideKeyword] = useState('');
|
||||||
@@ -1101,6 +1109,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
setTemplateGroupKey('basic');
|
setTemplateGroupKey('basic');
|
||||||
setTemplatePresetKey('operations_default');
|
setTemplatePresetKey('operations_default');
|
||||||
}
|
}
|
||||||
|
setHeaderValueExampleVisible(false);
|
||||||
setFieldGuideVisible(false);
|
setFieldGuideVisible(false);
|
||||||
setFieldGuideTarget('path');
|
setFieldGuideTarget('path');
|
||||||
setFieldGuideKeyword('');
|
setFieldGuideKeyword('');
|
||||||
@@ -2823,15 +2832,35 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
{t(getModeValueLabel(mode))}
|
{t(getModeValueLabel(mode))}
|
||||||
</Text>
|
</Text>
|
||||||
{mode === 'set_header' ? (
|
{mode === 'set_header' ? (
|
||||||
<Button
|
<Space spacing={6}>
|
||||||
size='small'
|
<Button
|
||||||
type='tertiary'
|
size='small'
|
||||||
onClick={formatSelectedOperationValueAsJson}
|
type='tertiary'
|
||||||
>
|
onClick={() =>
|
||||||
{t('格式化 JSON')}
|
setHeaderValueExampleVisible(true)
|
||||||
</Button>
|
}
|
||||||
|
>
|
||||||
|
{t('查看 JSON 示例')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='tertiary'
|
||||||
|
onClick={formatSelectedOperationValueAsJson}
|
||||||
|
>
|
||||||
|
{t('格式化 JSON')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{mode === 'set_header' ? (
|
||||||
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
size='small'
|
||||||
|
className='mt-1 mb-2 block'
|
||||||
|
>
|
||||||
|
{t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<TextArea
|
<TextArea
|
||||||
value={selectedOperation.value_text}
|
value={selectedOperation.value_text}
|
||||||
autosize={{ minRows: 1, maxRows: 4 }}
|
autosize={{ minRows: 1, maxRows: 4 }}
|
||||||
@@ -2842,11 +2871,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{getModeValueHelp(mode) ? (
|
|
||||||
<Text type='tertiary' size='small'>
|
|
||||||
{t(getModeValueHelp(mode))}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -3302,6 +3326,27 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('anthropic-beta JSON 示例')}
|
||||||
|
visible={headerValueExampleVisible}
|
||||||
|
width={760}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setHeaderValueExampleVisible(false)}
|
||||||
|
bodyStyle={{ padding: 16, paddingBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Space vertical align='start' spacing={12} style={{ width: '100%' }}>
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('下面是带注释的示例,仅用于参考;实际保存时请删除注释。')}
|
||||||
|
</Text>
|
||||||
|
<TextArea
|
||||||
|
value={HEADER_VALUE_JSONC_EXAMPLE}
|
||||||
|
readOnly
|
||||||
|
autosize={{ minRows: 16, maxRows: 20 }}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={null}
|
title={null}
|
||||||
visible={fieldGuideVisible}
|
visible={fieldGuideVisible}
|
||||||
|
|||||||
Reference in New Issue
Block a user