Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// contentSessionSeedPrefix prevents collisions between content-derived seeds
|
||||
// and explicit session IDs (e.g. "sess-xxx" or "compat_cc_xxx").
|
||||
const contentSessionSeedPrefix = "compat_cs_"
|
||||
|
||||
// contentStablePrefixSessionSeedPrefix distinguishes cache identities derived
|
||||
// only from request fields that remain stable across independent prompts.
|
||||
const contentStablePrefixSessionSeedPrefix = "compat_csp_"
|
||||
|
||||
// deriveOpenAIContentSessionSeed builds a stable session seed from an
|
||||
// OpenAI-format request body. Only fields constant across conversation turns
|
||||
// are included: model, tools/functions definitions, system/developer prompts,
|
||||
// instructions (Responses API), and the first user message.
|
||||
// Supports both Chat Completions (messages) and Responses API (input).
|
||||
func deriveOpenAIContentSessionSeed(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
const (
|
||||
modelField = iota
|
||||
toolsField
|
||||
functionsField
|
||||
instructionsField
|
||||
messagesField
|
||||
inputField
|
||||
contentSessionSeedFieldCount
|
||||
allContentSessionSeedFields = 1<<contentSessionSeedFieldCount - 1
|
||||
)
|
||||
var fields [contentSessionSeedFieldCount]gjson.Result
|
||||
var seen uint8
|
||||
// Match gjson.GetBytes by starting at the first root container, even when
|
||||
// malformed input has a non-JSON prefix.
|
||||
root := body
|
||||
for i := 0; i < len(body); i++ {
|
||||
switch body[i] {
|
||||
case '{':
|
||||
root = body[i:]
|
||||
goto scanRoot
|
||||
case '[':
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
scanRoot:
|
||||
nextKeyOffset := 1
|
||||
parseRawJSONView(root).ForEach(func(key, value gjson.Result) bool {
|
||||
if key.Index < nextKeyOffset || key.Index > len(root) {
|
||||
return false
|
||||
}
|
||||
// Result.ForEach can continue after the root '}' on malformed input.
|
||||
// The separator range excludes braces inside the preceding parsed value.
|
||||
if bytes.IndexByte(root[nextKeyOffset:key.Index], '}') >= 0 {
|
||||
return false
|
||||
}
|
||||
nextKeyOffset = value.Index + len(value.Raw)
|
||||
|
||||
field := -1
|
||||
switch key.Str {
|
||||
case "model":
|
||||
field = modelField
|
||||
case "tools":
|
||||
field = toolsField
|
||||
case "functions":
|
||||
field = functionsField
|
||||
case "instructions":
|
||||
field = instructionsField
|
||||
case "messages":
|
||||
field = messagesField
|
||||
case "input":
|
||||
field = inputField
|
||||
}
|
||||
if field < 0 {
|
||||
return true
|
||||
}
|
||||
mask := uint8(1 << field)
|
||||
if seen&mask == 0 {
|
||||
fields[field] = value
|
||||
seen |= mask
|
||||
}
|
||||
return seen != allContentSessionSeedFields
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
if model := fields[modelField].String(); model != "" {
|
||||
_, _ = b.WriteString("model=")
|
||||
_, _ = b.WriteString(model)
|
||||
}
|
||||
|
||||
if tools := fields[toolsField]; tools.Exists() && tools.IsArray() && tools.Raw != "[]" {
|
||||
_, _ = b.WriteString("|tools=")
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw)))
|
||||
}
|
||||
|
||||
if funcs := fields[functionsField]; funcs.Exists() && funcs.IsArray() && funcs.Raw != "[]" {
|
||||
_, _ = b.WriteString("|functions=")
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw)))
|
||||
}
|
||||
|
||||
if instr := fields[instructionsField].String(); instr != "" {
|
||||
_, _ = b.WriteString("|instructions=")
|
||||
_, _ = b.WriteString(instr)
|
||||
}
|
||||
|
||||
firstUserCaptured := false
|
||||
|
||||
msgs := fields[messagesField]
|
||||
if msgs.Exists() && msgs.IsArray() {
|
||||
msgs.ForEach(func(_, msg gjson.Result) bool {
|
||||
role := msg.Get("role").String()
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
_, _ = b.WriteString("|system=")
|
||||
if c := msg.Get("content"); c.Exists() {
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
|
||||
}
|
||||
case "user":
|
||||
if !firstUserCaptured {
|
||||
_, _ = b.WriteString("|first_user=")
|
||||
if c := msg.Get("content"); c.Exists() {
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
|
||||
}
|
||||
firstUserCaptured = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else if inp := fields[inputField]; inp.Exists() {
|
||||
if inp.Type == gjson.String {
|
||||
_, _ = b.WriteString("|input=")
|
||||
_, _ = b.WriteString(inp.String())
|
||||
} else if inp.IsArray() {
|
||||
inp.ForEach(func(_, item gjson.Result) bool {
|
||||
role := item.Get("role").String()
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
_, _ = b.WriteString("|system=")
|
||||
if c := item.Get("content"); c.Exists() {
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
|
||||
}
|
||||
case "user":
|
||||
if !firstUserCaptured {
|
||||
_, _ = b.WriteString("|first_user=")
|
||||
if c := item.Get("content"); c.Exists() {
|
||||
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
|
||||
}
|
||||
firstUserCaptured = true
|
||||
}
|
||||
}
|
||||
if !firstUserCaptured && item.Get("type").String() == "input_text" {
|
||||
_, _ = b.WriteString("|first_user=")
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
_, _ = b.WriteString(text)
|
||||
}
|
||||
firstUserCaptured = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return contentSessionSeedPrefix + b.String()
|
||||
}
|
||||
|
||||
// deriveOpenAIAnchoredContentSessionSeed returns the legacy content-derived
|
||||
// seed only when it contains a meaningful user/input anchor. This preserves
|
||||
// the existing session derivation while preventing model-only requests from
|
||||
// becoming a tenant-wide cache routing identity.
|
||||
func deriveOpenAIAnchoredContentSessionSeed(body []byte) string {
|
||||
if !hasOpenAIContentSessionUserAnchor(body) {
|
||||
return ""
|
||||
}
|
||||
return deriveOpenAIContentSessionSeed(body)
|
||||
}
|
||||
|
||||
func hasOpenAIContentSessionUserAnchor(body []byte) bool {
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
anchored := false
|
||||
messages.ForEach(func(_, message gjson.Result) bool {
|
||||
if strings.TrimSpace(message.Get("role").String()) != "user" {
|
||||
return true
|
||||
}
|
||||
anchored = hasMeaningfulOpenAIContent(message.Get("content"))
|
||||
return false
|
||||
})
|
||||
return anchored
|
||||
}
|
||||
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.Exists() {
|
||||
return false
|
||||
}
|
||||
if input.Type == gjson.String {
|
||||
return strings.TrimSpace(input.String()) != ""
|
||||
}
|
||||
if !input.IsArray() {
|
||||
return false
|
||||
}
|
||||
|
||||
anchored := false
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if strings.TrimSpace(item.Get("role").String()) == "user" {
|
||||
anchored = hasMeaningfulOpenAIContent(item.Get("content"))
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(item.Get("type").String()) == "input_text" {
|
||||
anchored = strings.TrimSpace(item.Get("text").String()) != ""
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return anchored
|
||||
}
|
||||
|
||||
func hasMeaningfulOpenAIContent(content gjson.Result) bool {
|
||||
if !content.Exists() || content.Type == gjson.Null {
|
||||
return false
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
return strings.TrimSpace(content.String()) != ""
|
||||
}
|
||||
if !content.IsArray() {
|
||||
normalized, ok := normalizeNonEmptyCompatSeedJSON(content)
|
||||
return ok && strings.TrimSpace(normalized) != ""
|
||||
}
|
||||
|
||||
meaningful := false
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Type == gjson.String {
|
||||
meaningful = strings.TrimSpace(item.String()) != ""
|
||||
} else if text := item.Get("text"); text.Exists() {
|
||||
meaningful = strings.TrimSpace(text.String()) != ""
|
||||
} else {
|
||||
_, meaningful = normalizeNonEmptyCompatSeedJSON(item)
|
||||
}
|
||||
return !meaningful
|
||||
})
|
||||
return meaningful
|
||||
}
|
||||
|
||||
// deriveOpenAIStablePrefixSessionSeed builds a seed from the reusable prefix
|
||||
// of an OpenAI-format request. User and assistant content are deliberately
|
||||
// excluded so independent prompts with the same system/tool prefix can share
|
||||
// an upstream prompt-cache routing identity.
|
||||
//
|
||||
// An empty result means the request has no meaningful stable prefix. Callers
|
||||
// must then use a narrower fallback instead of grouping all requests by tenant
|
||||
// and model alone.
|
||||
func deriveOpenAIStablePrefixSessionSeed(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
hasStablePrefix := false
|
||||
appendJSON := func(label string, value gjson.Result) {
|
||||
normalized, ok := normalizeNonEmptyCompatSeedJSON(value)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _ = b.WriteString("|")
|
||||
_, _ = b.WriteString(label)
|
||||
_, _ = b.WriteString("=")
|
||||
_, _ = b.WriteString(normalized)
|
||||
hasStablePrefix = true
|
||||
}
|
||||
|
||||
if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
|
||||
appendJSON("tools", tools)
|
||||
}
|
||||
if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() {
|
||||
appendJSON("functions", funcs)
|
||||
}
|
||||
if instructions := gjson.GetBytes(body, "instructions"); strings.TrimSpace(instructions.String()) != "" {
|
||||
appendJSON("instructions", instructions)
|
||||
}
|
||||
|
||||
appendSystemMessages := func(items gjson.Result) {
|
||||
items.ForEach(func(_, item gjson.Result) bool {
|
||||
role := strings.TrimSpace(item.Get("role").String())
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
appendJSON(role, item.Get("content"))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
appendSystemMessages(messages)
|
||||
} else if input := gjson.GetBytes(body, "input"); input.Exists() && input.IsArray() {
|
||||
appendSystemMessages(input)
|
||||
}
|
||||
|
||||
if !hasStablePrefix {
|
||||
return ""
|
||||
}
|
||||
return contentStablePrefixSessionSeedPrefix + b.String()
|
||||
}
|
||||
|
||||
func normalizeNonEmptyCompatSeedJSON(value gjson.Result) (string, bool) {
|
||||
if !value.Exists() || value.Type == gjson.Null {
|
||||
return "", false
|
||||
}
|
||||
normalized := normalizeCompatSeedJSON(json.RawMessage(value.Raw))
|
||||
switch normalized {
|
||||
case "", `""`, "[]", "{}", "null":
|
||||
return "", false
|
||||
default:
|
||||
return normalized, true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user