feat: add pass_headers op, grouped presets (incl. Gemini 4K), and robust JSON fallback

This commit is contained in:
Seefs
2026-02-22 17:16:57 +08:00
parent 11b0788b68
commit 303fff44e7
3 changed files with 2002 additions and 412 deletions

View File

@@ -34,7 +34,7 @@ type ConditionOperation struct {
type ParamOperation struct { type ParamOperation struct {
Path string `json:"path"` Path string `json:"path"`
Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects, set_header, delete_header, copy_header, move_header, sync_fields Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects, set_header, delete_header, copy_header, move_header, pass_headers, sync_fields
Value interface{} `json:"value"` Value interface{} `json:"value"`
KeepOrigin bool `json:"keep_origin"` KeepOrigin bool `json:"keep_origin"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
@@ -494,6 +494,19 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err == nil { if err == nil {
contextJSON, err = marshalContextJSON(context) contextJSON, err = marshalContextJSON(context)
} }
case "pass_headers":
headerNames, parseErr := parseHeaderPassThroughNames(op.Value)
if parseErr != nil {
return "", parseErr
}
for _, headerName := range headerNames {
if err = copyHeaderInContext(context, headerName, headerName, op.KeepOrigin); err != nil {
break
}
}
if err == nil {
contextJSON, err = marshalContextJSON(context)
}
case "sync_fields": case "sync_fields":
result, err = syncFieldsBetweenTargets(result, context, op.From, op.To) result, err = syncFieldsBetweenTargets(result, context, op.From, op.To)
if err == nil { if err == nil {
@@ -678,6 +691,80 @@ func deleteHeaderOverrideInContext(context map[string]interface{}, headerName st
return nil return nil
} }
func parseHeaderPassThroughNames(value interface{}) ([]string, error) {
normalizeNames := func(values []string) []string {
names := lo.FilterMap(values, func(item string, _ int) (string, bool) {
headerName := strings.TrimSpace(item)
if headerName == "" {
return "", false
}
return headerName, true
})
return lo.Uniq(names)
}
switch raw := value.(type) {
case nil:
return nil, fmt.Errorf("pass_headers value is required")
case string:
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, fmt.Errorf("pass_headers value is required")
}
if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") {
var parsed interface{}
if err := common.UnmarshalJsonStr(trimmed, &parsed); err == nil {
return parseHeaderPassThroughNames(parsed)
}
}
names := normalizeNames(strings.Split(trimmed, ","))
if len(names) == 0 {
return nil, fmt.Errorf("pass_headers value is invalid")
}
return names, nil
case []interface{}:
names := lo.FilterMap(raw, func(item interface{}, _ int) (string, bool) {
headerName := strings.TrimSpace(fmt.Sprintf("%v", item))
if headerName == "" {
return "", false
}
return headerName, true
})
names = lo.Uniq(names)
if len(names) == 0 {
return nil, fmt.Errorf("pass_headers value is invalid")
}
return names, nil
case map[string]interface{}:
candidates := make([]string, 0, 8)
if headersRaw, ok := raw["headers"]; ok {
names, err := parseHeaderPassThroughNames(headersRaw)
if err == nil {
candidates = append(candidates, names...)
}
}
if namesRaw, ok := raw["names"]; ok {
names, err := parseHeaderPassThroughNames(namesRaw)
if err == nil {
candidates = append(candidates, names...)
}
}
if headerRaw, ok := raw["header"]; ok {
names, err := parseHeaderPassThroughNames(headerRaw)
if err == nil {
candidates = append(candidates, names...)
}
}
names := normalizeNames(candidates)
if len(names) == 0 {
return nil, fmt.Errorf("pass_headers value is invalid")
}
return names, nil
default:
return nil, fmt.Errorf("pass_headers value must be string, array or object")
}
}
type syncTarget struct { type syncTarget struct {
kind string kind string
key string key string

View File

@@ -71,6 +71,7 @@ import {
IconServer, IconServer,
IconSetting, IconSetting,
IconCode, IconCode,
IconCopy,
IconGlobe, IconGlobe,
IconBolt, IconBolt,
IconSearch, IconSearch,
@@ -95,6 +96,28 @@ const REGION_EXAMPLE = {
'claude-3-5-sonnet-20240620': 'europe-west1', 'claude-3-5-sonnet-20240620': 'europe-west1',
}; };
const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
temperature: 0,
};
const PARAM_OVERRIDE_OPERATIONS_TEMPLATE = {
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'openai/',
},
],
logic: 'AND',
},
],
};
// 支持并且已适配通过接口获取模型列表的渠道类型 // 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([ const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
@@ -270,7 +293,7 @@ const EditChannelModal = (props) => {
}; };
} }
return { return {
tagLabel: 'Custom JSON', tagLabel: t('自定义 JSON'),
tagColor: 'orange', tagColor: 'orange',
preview: pretty, preview: pretty,
}; };
@@ -608,6 +631,100 @@ const EditChannelModal = (props) => {
} }
}; };
const copyParamOverrideJson = async () => {
const raw =
typeof inputs.param_override === 'string'
? inputs.param_override.trim()
: '';
if (!raw) {
showInfo(t('暂无可复制 JSON'));
return;
}
let content = raw;
if (verifyJSON(raw)) {
try {
content = JSON.stringify(JSON.parse(raw), null, 2);
} catch (error) {
content = raw;
}
}
const ok = await copy(content);
if (ok) {
showSuccess(t('参数覆盖 JSON 已复制'));
} else {
showError(t('复制失败'));
}
};
const parseParamOverrideInput = () => {
const raw =
typeof inputs.param_override === 'string'
? inputs.param_override.trim()
: '';
if (!raw) return null;
if (!verifyJSON(raw)) {
throw new Error(t('当前参数覆盖不是合法的 JSON'));
}
return JSON.parse(raw);
};
const applyParamOverrideTemplate = (
templateType = 'operations',
applyMode = 'fill',
) => {
try {
const parsedCurrent = parseParamOverrideInput();
if (templateType === 'legacy') {
if (applyMode === 'fill') {
handleInputChange(
'param_override',
JSON.stringify(PARAM_OVERRIDE_LEGACY_TEMPLATE, null, 2),
);
return;
}
const currentLegacy =
parsedCurrent &&
typeof parsedCurrent === 'object' &&
!Array.isArray(parsedCurrent) &&
!Array.isArray(parsedCurrent.operations)
? parsedCurrent
: {};
const merged = {
...PARAM_OVERRIDE_LEGACY_TEMPLATE,
...currentLegacy,
};
handleInputChange('param_override', JSON.stringify(merged, null, 2));
return;
}
if (applyMode === 'fill') {
handleInputChange(
'param_override',
JSON.stringify(PARAM_OVERRIDE_OPERATIONS_TEMPLATE, null, 2),
);
return;
}
const currentOperations =
parsedCurrent &&
typeof parsedCurrent === 'object' &&
!Array.isArray(parsedCurrent) &&
Array.isArray(parsedCurrent.operations)
? parsedCurrent.operations
: [];
const merged = {
operations: [
...currentOperations,
...PARAM_OVERRIDE_OPERATIONS_TEMPLATE.operations,
],
};
handleInputChange('param_override', JSON.stringify(merged, null, 2));
} catch (error) {
showError(error.message || t('模板应用失败'));
}
};
const loadChannel = async () => { const loadChannel = async () => {
setLoading(true); setLoading(true);
let res = await API.get(`/api/channel/${channelId}`); let res = await API.get(`/api/channel/${channelId}`);
@@ -3119,51 +3236,18 @@ const EditChannelModal = (props) => {
<Button <Button
size='small' size='small'
onClick={() => onClick={() =>
handleInputChange( applyParamOverrideTemplate('operations', 'fill')
'param_override',
JSON.stringify({ temperature: 0 }, null, 2),
)
} }
> >
{t('旧格式模板')} {t('填充新模板')}
</Button> </Button>
<Button <Button
size='small' size='small'
onClick={() => onClick={() =>
handleInputChange( applyParamOverrideTemplate('legacy', 'fill')
'param_override',
JSON.stringify(
{
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'gpt',
},
],
logic: 'AND',
},
],
},
null,
2,
),
)
} }
> >
{t('新格式模板')} {t('填充旧模板')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => handleInputChange('param_override', '')}
>
{t('不更改')}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -3171,20 +3255,33 @@ const EditChannelModal = (props) => {
{t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')} {t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')}
</Text> </Text>
<div <div
className='mt-2 rounded-lg border p-3' className='mt-2 rounded-xl p-3'
style={{ backgroundColor: 'var(--semi-color-fill-0)' }} style={{
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-fill-2)',
}}
> >
<div className='flex items-center justify-between mb-2'> <div className='flex items-center justify-between mb-2'>
<Tag color={paramOverrideMeta.tagColor}> <Tag color={paramOverrideMeta.tagColor}>
{paramOverrideMeta.tagLabel} {paramOverrideMeta.tagLabel}
</Tag> </Tag>
<Button <Space spacing={8}>
size='small' <Button
type='tertiary' size='small'
onClick={() => setParamOverrideEditorVisible(true)} icon={<IconCopy />}
> type='tertiary'
{t('编辑')} onClick={copyParamOverrideJson}
</Button> >
{t('复制')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setParamOverrideEditorVisible(true)}
>
{t('编辑')}
</Button>
</Space>
</div> </div>
<pre className='mb-0 text-xs leading-5 whitespace-pre-wrap break-all max-h-56 overflow-auto'> <pre className='mb-0 text-xs leading-5 whitespace-pre-wrap break-all max-h-56 overflow-auto'>
{paramOverrideMeta.preview} {paramOverrideMeta.preview}