Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions backend/internal/service/openai_content_session_seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package service

import (
"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_"

// 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 ""
}

var b strings.Builder

if model := gjson.GetBytes(body, "model").String(); model != "" {
_, _ = b.WriteString("model=")
_, _ = b.WriteString(model)
}

if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() && tools.Raw != "[]" {
_, _ = b.WriteString("|tools=")
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw)))
}

if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() && funcs.Raw != "[]" {
_, _ = b.WriteString("|functions=")
_, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw)))
}

if instr := gjson.GetBytes(body, "instructions").String(); instr != "" {
_, _ = b.WriteString("|instructions=")
_, _ = b.WriteString(instr)
}

firstUserCaptured := false

msgs := gjson.GetBytes(body, "messages")
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 := gjson.GetBytes(body, "input"); 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()
}
218 changes: 218 additions & 0 deletions backend/internal/service/openai_content_session_seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package service

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestDeriveOpenAIContentSessionSeed_EmptyInputs(t *testing.T) {
require.Empty(t, deriveOpenAIContentSessionSeed(nil))
require.Empty(t, deriveOpenAIContentSessionSeed([]byte{}))
require.Empty(t, deriveOpenAIContentSessionSeed([]byte(`{}`)))
}

func TestDeriveOpenAIContentSessionSeed_ModelOnly(t *testing.T) {
seed := deriveOpenAIContentSessionSeed([]byte(`{"model":"gpt-5.4"}`))
require.Contains(t, seed, contentSessionSeedPrefix)
require.Contains(t, seed, "model=gpt-5.4")
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StableAcrossTurns(t *testing.T) {
turn1 := []byte(`{
"model": "gpt-5.4",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]
}`)
turn2 := []byte(`{
"model": "gpt-5.4",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"}
]
}`)
s1 := deriveOpenAIContentSessionSeed(turn1)
s2 := deriveOpenAIContentSessionSeed(turn2)
require.Equal(t, s1, s2, "seed should be stable across later turns")
require.NotEmpty(t, s1)
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentFirstUserDiffers(t *testing.T) {
req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question A"}]}`)
req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question B"}]}`)
s1 := deriveOpenAIContentSessionSeed(req1)
s2 := deriveOpenAIContentSessionSeed(req2)
require.NotEqual(t, s1, s2)
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentSystemDiffers(t *testing.T) {
req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"A"},{"role":"user","content":"Hi"}]}`)
req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"B"},{"role":"user","content":"Hi"}]}`)
s1 := deriveOpenAIContentSessionSeed(req1)
s2 := deriveOpenAIContentSessionSeed(req2)
require.NotEqual(t, s1, s2)
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentModelDiffers(t *testing.T) {
req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Hi"}]}`)
req2 := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"Hi"}]}`)
s1 := deriveOpenAIContentSessionSeed(req1)
s2 := deriveOpenAIContentSessionSeed(req2)
require.NotEqual(t, s1, s2)
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_WithTools(t *testing.T) {
withTools := []byte(`{
"model": "gpt-5.4",
"tools": [{"type":"function","function":{"name":"get_weather"}}],
"messages": [{"role": "user", "content": "Hello"}]
}`)
withoutTools := []byte(`{
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "Hello"}]
}`)
s1 := deriveOpenAIContentSessionSeed(withTools)
s2 := deriveOpenAIContentSessionSeed(withoutTools)
require.NotEqual(t, s1, s2, "tools should affect the seed")
require.Contains(t, s1, "|tools=")
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_WithFunctions(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"functions": [{"name":"get_weather","parameters":{}}],
"messages": [{"role": "user", "content": "Hello"}]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|functions=")
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DeveloperRole(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"messages": [
{"role": "developer", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|system=")
require.Contains(t, seed, "|first_user=")
}

func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StructuredContent(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"messages": [
{"role": "user", "content": [{"type":"text","text":"Hello"}]}
]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.NotEmpty(t, seed)
require.Contains(t, seed, "|first_user=")
}

func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputString(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":"Hello, how are you?"}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|input=Hello, how are you?")
}

func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputArray(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"input": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|system=")
require.Contains(t, seed, "|first_user=")
}

func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_WithInstructions(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"instructions": "You are a coding assistant.",
"input": "Write a hello world"
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|instructions=You are a coding assistant.")
require.Contains(t, seed, "|input=Write a hello world")
}

func TestDeriveOpenAIContentSessionSeed_Deterministic(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]
}`)
s1 := deriveOpenAIContentSessionSeed(body)
s2 := deriveOpenAIContentSessionSeed(body)
require.Equal(t, s1, s2, "seed must be deterministic")
}

func TestDeriveOpenAIContentSessionSeed_PrefixPresent(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Hi"}]}`)
seed := deriveOpenAIContentSessionSeed(body)
require.True(t, len(seed) > len(contentSessionSeedPrefix))
require.Equal(t, contentSessionSeedPrefix, seed[:len(contentSessionSeedPrefix)])
}

func TestDeriveOpenAIContentSessionSeed_EmptyToolsIgnored(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[],"messages":[{"role":"user","content":"Hi"}]}`)
seed := deriveOpenAIContentSessionSeed(body)
require.NotContains(t, seed, "|tools=")
}

func TestDeriveOpenAIContentSessionSeed_MessagesPreferredOverInput(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "from messages"}],
"input": "from input"
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|first_user=")
require.NotContains(t, seed, "|input=")
}

func TestDeriveOpenAIContentSessionSeed_JSONCanonicalisation(t *testing.T) {
compact := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather"}}],"messages":[{"role":"user","content":"Hi"}]}`)
spaced := []byte(`{
"model": "gpt-5.4",
"tools": [
{ "type" : "function", "function": { "description": "Get weather", "name": "get_weather" } }
],
"messages": [ { "role": "user", "content": "Hi" } ]
}`)
s1 := deriveOpenAIContentSessionSeed(compact)
s2 := deriveOpenAIContentSessionSeed(spaced)
require.Equal(t, s1, s2, "different formatting of identical JSON should produce the same seed")
}

func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputTextTypedItem(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"input": [{"type": "input_text", "text": "Hello world"}]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|first_user=")
require.Contains(t, seed, "Hello world")
}

func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_TypedMessageItem(t *testing.T) {
body := []byte(`{
"model": "gpt-5.4",
"input": [{"type": "message", "role": "user", "content": "Hello from typed message"}]
}`)
seed := deriveOpenAIContentSessionSeed(body)
require.Contains(t, seed, "|first_user=")
require.Contains(t, seed, "Hello from typed message")
}
4 changes: 4 additions & 0 deletions backend/internal/service/openai_gateway_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,7 @@ func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) str
// 1. Header: session_id
// 2. Header: conversation_id
// 3. Body: prompt_cache_key (opencode)
// 4. Body: content-based fallback (model + system + tools + first user message)
func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) string {
if c == nil {
return ""
Expand All @@ -1056,6 +1057,9 @@ func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte)
if sessionID == "" && len(body) > 0 {
sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
}
if sessionID == "" && len(body) > 0 {
sessionID = deriveOpenAIContentSessionSeed(body)
}
if sessionID == "" {
return ""
}
Expand Down
Loading
Loading