diff --git a/relay/common/override.go b/relay/common/override.go index 9ac007ecd..31101ef01 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -34,7 +34,7 @@ type ConditionOperation struct { type ParamOperation struct { 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 + 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 Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -494,6 +494,11 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte if err == nil { contextJSON, err = marshalContextJSON(context) } + case "sync_fields": + result, err = syncFieldsBetweenTargets(result, context, op.From, op.To) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } @@ -673,6 +678,119 @@ func deleteHeaderOverrideInContext(context map[string]interface{}, headerName st return nil } +type syncTarget struct { + kind string + key string +} + +func parseSyncTarget(spec string) (syncTarget, error) { + raw := strings.TrimSpace(spec) + if raw == "" { + return syncTarget{}, fmt.Errorf("sync_fields target is required") + } + + idx := strings.Index(raw, ":") + if idx < 0 { + // Backward compatibility: treat bare value as JSON path. + return syncTarget{ + kind: "json", + key: raw, + }, nil + } + + kind := strings.ToLower(strings.TrimSpace(raw[:idx])) + key := strings.TrimSpace(raw[idx+1:]) + if key == "" { + return syncTarget{}, fmt.Errorf("sync_fields target key is required: %s", raw) + } + + switch kind { + case "json", "body": + return syncTarget{ + kind: "json", + key: key, + }, nil + case "header": + return syncTarget{ + kind: "header", + key: key, + }, nil + default: + return syncTarget{}, fmt.Errorf("sync_fields target prefix is invalid: %s", raw) + } +} + +func readSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget) (interface{}, bool, error) { + switch target.kind { + case "json": + path := processNegativeIndex(jsonStr, target.key) + value := gjson.Get(jsonStr, path) + 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 + case "header": + value, ok := getHeaderValueFromContext(context, target.key) + if !ok || strings.TrimSpace(value) == "" { + return nil, false, nil + } + return value, true, nil + default: + return nil, false, fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) + } +} + +func writeSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget, value interface{}) (string, error) { + switch target.kind { + case "json": + path := processNegativeIndex(jsonStr, target.key) + nextJSON, err := sjson.Set(jsonStr, path, value) + if err != nil { + return "", err + } + return nextJSON, nil + case "header": + if err := setHeaderOverrideInContext(context, target.key, value, false); err != nil { + return "", err + } + return jsonStr, nil + default: + return "", fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) + } +} + +func syncFieldsBetweenTargets(jsonStr string, context map[string]interface{}, fromSpec string, toSpec string) (string, error) { + fromTarget, err := parseSyncTarget(fromSpec) + if err != nil { + return "", err + } + toTarget, err := parseSyncTarget(toSpec) + if err != nil { + return "", err + } + + fromValue, fromExists, err := readSyncTargetValue(jsonStr, context, fromTarget) + if err != nil { + return "", err + } + toValue, toExists, err := readSyncTargetValue(jsonStr, context, toTarget) + if err != nil { + return "", err + } + + // If one side exists and the other side is missing, sync the missing side. + if fromExists && !toExists { + return writeSyncTargetValue(jsonStr, context, toTarget, fromValue) + } + if toExists && !fromExists { + return writeSyncTargetValue(jsonStr, context, fromTarget, toValue) + } + return jsonStr, nil +} + func ensureMapKeyInContext(context map[string]interface{}, key string) map[string]interface{} { if context == nil { return map[string]interface{}{} diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 653a87f6a..a37eb78f9 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1057,6 +1057,113 @@ func TestApplyParamOverrideCopyHeaderFromRequestHeaders(t *testing.T) { assertJSONEqual(t, `{"temperature":0.1}`, string(out)) } +func TestApplyParamOverrideSyncFieldsHeaderToJSON(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers_raw": map[string]interface{}{ + "session_id": "sess-123", + }, + "request_headers": map[string]interface{}{ + "session_id": "sess-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"sess-123"}`, string(out)) +} + +func TestApplyParamOverrideSyncFieldsJSONToHeader(t *testing.T) { + input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-abc"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{} + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-abc"}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["session_id"] != "cache-abc" { + t.Fatalf("expected session_id to be synced from prompt_cache_key, got: %v", headers["session_id"]) + } +} + +func TestApplyParamOverrideSyncFieldsNoChangeWhenBothExist(t *testing.T) { + input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-body"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers_raw": map[string]interface{}{ + "session_id": "cache-header", + }, + "request_headers": map[string]interface{}{ + "session_id": "cache-header", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-body"}`, string(out)) + + headers, _ := ctx["header_override"].(map[string]interface{}) + if headers != nil { + if _, exists := headers["session_id"]; exists { + t.Fatalf("expected no override when both sides already have value") + } + } +} + +func TestApplyParamOverrideSyncFieldsInvalidTarget(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "foo:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + func TestApplyParamOverrideSetHeaderKeepOrigin(t *testing.T) { input := []byte(`{"temperature":0.7}`) override := map[string]interface{}{ diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 1975e83fd..42051d5bf 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -58,6 +58,7 @@ const OPERATION_MODE_OPTIONS = [ { label: 'JSON · to_upper', value: 'to_upper' }, { label: 'Control · return_error', value: 'return_error' }, { label: 'Control · prune_objects', value: 'prune_objects' }, + { label: 'Control · sync_fields', value: 'sync_fields' }, { label: 'Header · set_header', value: 'set_header' }, { label: 'Header · delete_header', value: 'delete_header' }, { label: 'Header · copy_header', value: 'copy_header' }, @@ -101,6 +102,7 @@ const MODE_META = { to_upper: { path: true }, return_error: { value: true }, prune_objects: { pathOptional: true, value: true }, + sync_fields: { from: true, to: true }, set_header: { path: true, value: true, keepOrigin: true }, delete_header: { path: true }, copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, @@ -124,9 +126,16 @@ const FROM_REQUIRED_MODES = new Set([ 'regex_replace', 'copy_header', 'move_header', + 'sync_fields', ]); -const TO_REQUIRED_MODES = new Set(['copy', 'move', 'copy_header', 'move_header']); +const TO_REQUIRED_MODES = new Set([ + 'copy', + 'move', + 'copy_header', + 'move_header', + 'sync_fields', +]); const MODE_DESCRIPTIONS = { set: 'Set JSON value at path', @@ -146,12 +155,18 @@ const MODE_DESCRIPTIONS = { to_upper: 'Convert string to upper case', return_error: 'Stop processing and return custom error', prune_objects: 'Remove objects matching conditions', + sync_fields: 'Sync two fields when one exists and the other is missing', set_header: 'Set runtime override header', delete_header: 'Delete runtime override header', copy_header: 'Copy header from from -> to', move_header: 'Move header from from -> to', }; +const SYNC_TARGET_TYPE_OPTIONS = [ + { label: 'JSON', value: 'json' }, + { label: 'Header', value: 'header' }, +]; + const OPERATION_PATH_SUGGESTIONS = [ 'model', 'temperature', @@ -353,6 +368,13 @@ const PARAM_OVERRIDE_JSON_SCHEMA = { }, then: { required: ['value'] }, }, + { + if: { + properties: { mode: { const: 'sync_fields' } }, + required: ['mode'], + }, + then: { required: ['from', 'to'] }, + }, { if: { properties: { mode: { const: 'set_header' } }, @@ -415,6 +437,26 @@ const parseLooseValue = (valueText) => { } }; +const parseSyncTargetSpec = (spec) => { + const raw = String(spec ?? '').trim(); + if (!raw) return { type: 'json', key: '' }; + const idx = raw.indexOf(':'); + if (idx < 0) return { type: 'json', key: raw }; + const prefix = raw.slice(0, idx).trim().toLowerCase(); + const key = raw.slice(idx + 1).trim(); + if (prefix === 'header') { + return { type: 'header', key }; + } + return { type: 'json', key }; +}; + +const buildSyncTargetSpec = (type, key) => { + const normalizedType = type === 'header' ? 'header' : 'json'; + const normalizedKey = String(key ?? '').trim(); + if (!normalizedKey) return ''; + return `${normalizedType}:${normalizedKey}`; +}; + const normalizeCondition = (condition = {}) => ({ id: nextLocalId(), path: typeof condition.path === 'string' ? condition.path : '', @@ -1028,6 +1070,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const mode = operation.mode || 'set'; const meta = MODE_META[mode] || MODE_META.set; const conditions = operation.conditions || []; + const syncFromTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(operation.from) + : null; + const syncToTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(operation.to) + : null; return (
@@ -1146,7 +1196,107 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
) : null} - {meta.from || meta.to === false || meta.to ? ( + {mode === 'sync_fields' ? ( +
+ + sync endpoints + + + + + from endpoint + +
+ + updateOperation(operation.id, { + from: buildSyncTargetSpec( + syncFromTarget?.type || 'json', + nextKey, + ), + }) + } + /> +
+ + + + to endpoint + +
+ + updateOperation(operation.id, { + to: buildSyncTargetSpec( + syncToTarget?.type || 'json', + nextKey, + ), + }) + } + /> +
+ +
+ + + updateOperation(operation.id, { + from: 'header:session_id', + to: 'json:prompt_cache_key', + }) + } + > + {'header:session_id -> json:prompt_cache_key'} + + + updateOperation(operation.id, { + from: 'json:prompt_cache_key', + to: 'header:session_id', + }) + } + > + {'json:prompt_cache_key -> header:session_id'} + + +
+ ) : meta.from || meta.to === false || meta.to ? ( {meta.from || meta.to === false ? (