Skip to content

Commit fad0750

Browse files
author
jimmy-coder
committed
fix(openai): inject stable compat prompt_cache_key for codex oauth chat-completions path
1 parent 6da5fa0 commit fad0750

3 files changed

Lines changed: 166 additions & 6 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
7+
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
8+
)
9+
10+
const compatPromptCacheKeyPrefix = "compat_cc_"
11+
12+
func shouldAutoInjectPromptCacheKeyForCompat(model string) bool {
13+
switch normalizeCodexModel(strings.TrimSpace(model)) {
14+
case "gpt-5.4", "gpt-5.3-codex":
15+
return true
16+
default:
17+
return false
18+
}
19+
}
20+
21+
func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string {
22+
if req == nil {
23+
return ""
24+
}
25+
26+
normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel))
27+
if normalizedModel == "" {
28+
normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model))
29+
}
30+
if normalizedModel == "" {
31+
normalizedModel = strings.TrimSpace(req.Model)
32+
}
33+
34+
seedParts := []string{"model=" + normalizedModel}
35+
if req.ReasoningEffort != "" {
36+
seedParts = append(seedParts, "reasoning_effort="+strings.TrimSpace(req.ReasoningEffort))
37+
}
38+
if len(req.ToolChoice) > 0 {
39+
seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice))
40+
}
41+
if len(req.Tools) > 0 {
42+
if raw, err := json.Marshal(req.Tools); err == nil {
43+
seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw))
44+
}
45+
}
46+
if len(req.Functions) > 0 {
47+
if raw, err := json.Marshal(req.Functions); err == nil {
48+
seedParts = append(seedParts, "functions="+normalizeCompatSeedJSON(raw))
49+
}
50+
}
51+
52+
firstUserCaptured := false
53+
for _, msg := range req.Messages {
54+
switch strings.TrimSpace(msg.Role) {
55+
case "system":
56+
seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(msg.Content))
57+
case "user":
58+
if !firstUserCaptured {
59+
seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content))
60+
firstUserCaptured = true
61+
}
62+
}
63+
}
64+
65+
return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|"))
66+
}
67+
68+
func normalizeCompatSeedJSON(v json.RawMessage) string {
69+
if len(v) == 0 {
70+
return ""
71+
}
72+
var tmp any
73+
if err := json.Unmarshal(v, &tmp); err != nil {
74+
return string(v)
75+
}
76+
out, err := json.Marshal(tmp)
77+
if err != nil {
78+
return string(v)
79+
}
80+
return string(out)
81+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func mustRawJSON(t *testing.T, s string) json.RawMessage {
12+
t.Helper()
13+
return json.RawMessage(s)
14+
}
15+
16+
func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) {
17+
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4"))
18+
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3"))
19+
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex"))
20+
require.False(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-4o"))
21+
}
22+
23+
func TestDeriveCompatPromptCacheKey_StableAcrossLaterTurns(t *testing.T) {
24+
base := &apicompat.ChatCompletionsRequest{
25+
Model: "gpt-5.4",
26+
Messages: []apicompat.ChatMessage{
27+
{Role: "system", Content: mustRawJSON(t, `"You are helpful."`)},
28+
{Role: "user", Content: mustRawJSON(t, `"Hello"`)},
29+
},
30+
}
31+
extended := &apicompat.ChatCompletionsRequest{
32+
Model: "gpt-5.4",
33+
Messages: []apicompat.ChatMessage{
34+
{Role: "system", Content: mustRawJSON(t, `"You are helpful."`)},
35+
{Role: "user", Content: mustRawJSON(t, `"Hello"`)},
36+
{Role: "assistant", Content: mustRawJSON(t, `"Hi there!"`)},
37+
{Role: "user", Content: mustRawJSON(t, `"How are you?"`)},
38+
},
39+
}
40+
41+
k1 := deriveCompatPromptCacheKey(base, "gpt-5.4")
42+
k2 := deriveCompatPromptCacheKey(extended, "gpt-5.4")
43+
require.Equal(t, k1, k2, "cache key should be stable across later turns")
44+
require.NotEmpty(t, k1)
45+
}
46+
47+
func TestDeriveCompatPromptCacheKey_DiffersAcrossSessions(t *testing.T) {
48+
req1 := &apicompat.ChatCompletionsRequest{
49+
Model: "gpt-5.4",
50+
Messages: []apicompat.ChatMessage{
51+
{Role: "user", Content: mustRawJSON(t, `"Question A"`)},
52+
},
53+
}
54+
req2 := &apicompat.ChatCompletionsRequest{
55+
Model: "gpt-5.4",
56+
Messages: []apicompat.ChatMessage{
57+
{Role: "user", Content: mustRawJSON(t, `"Question B"`)},
58+
},
59+
}
60+
61+
k1 := deriveCompatPromptCacheKey(req1, "gpt-5.4")
62+
k2 := deriveCompatPromptCacheKey(req2, "gpt-5.4")
63+
require.NotEqual(t, k1, k2, "different first user messages should yield different keys")
64+
}

backend/internal/service/openai_gateway_chat_completions.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,38 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
4343
clientStream := chatReq.Stream
4444
includeUsage := chatReq.StreamOptions != nil && chatReq.StreamOptions.IncludeUsage
4545

46-
// 2. Convert to Responses and forward
46+
// 2. Resolve model mapping early so compat prompt_cache_key injection can
47+
// derive a stable seed from the final upstream model family.
48+
mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
49+
50+
promptCacheKey = strings.TrimSpace(promptCacheKey)
51+
compatPromptCacheInjected := false
52+
if promptCacheKey == "" && account.Type == AccountTypeOAuth && shouldAutoInjectPromptCacheKeyForCompat(mappedModel) {
53+
promptCacheKey = deriveCompatPromptCacheKey(&chatReq, mappedModel)
54+
compatPromptCacheInjected = promptCacheKey != ""
55+
}
56+
57+
// 3. Convert to Responses and forward
4758
// ChatCompletionsToResponses always sets Stream=true (upstream always streams).
4859
responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq)
4960
if err != nil {
5061
return nil, fmt.Errorf("convert chat completions to responses: %w", err)
5162
}
52-
53-
// 3. Model mapping
54-
mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
5563
responsesReq.Model = mappedModel
5664

57-
logger.L().Debug("openai chat_completions: model mapping applied",
65+
logFields := []zap.Field{
5866
zap.Int64("account_id", account.ID),
5967
zap.String("original_model", originalModel),
6068
zap.String("mapped_model", mappedModel),
6169
zap.Bool("stream", clientStream),
62-
)
70+
}
71+
if compatPromptCacheInjected {
72+
logFields = append(logFields,
73+
zap.Bool("compat_prompt_cache_key_injected", true),
74+
zap.String("compat_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)),
75+
)
76+
}
77+
logger.L().Debug("openai chat_completions: model mapping applied", logFields...)
6378

6479
// 4. Marshal Responses request body, then apply OAuth codex transform
6580
responsesBody, err := json.Marshal(responsesReq)

0 commit comments

Comments
 (0)