Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
0537a490
Unverified
Commit
0537a490
authored
Apr 27, 2026
by
Oliver Li
Committed by
GitHub
Apr 27, 2026
Browse files
Merge branch 'Wei-Shaw:main' into vertex
parents
3f05ef2a
c92b88e3
Changes
5
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/openai_images.go
View file @
0537a490
...
@@ -117,12 +117,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
...
@@ -117,12 +117,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
return
return
}
}
sessionHash
:=
""
sessionHash
:=
h
.
gatewayService
.
GenerateExplicitSessionHash
(
c
,
body
)
if
parsed
.
Multipart
{
sessionHash
=
h
.
gatewayService
.
GenerateSessionHashWithFallback
(
c
,
nil
,
parsed
.
StickySessionSeed
())
}
else
{
sessionHash
=
h
.
gatewayService
.
GenerateSessionHash
(
c
,
body
)
}
maxAccountSwitches
:=
h
.
maxAccountSwitches
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
switchCount
:=
0
...
...
backend/internal/pkg/apicompat/anthropic_responses_test.go
View file @
0537a490
...
@@ -258,6 +258,48 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) {
...
@@ -258,6 +258,48 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) {
assert
.
Equal
(
t
,
"tool_use"
,
anth
.
Content
[
1
]
.
Type
)
assert
.
Equal
(
t
,
"tool_use"
,
anth
.
Content
[
1
]
.
Type
)
assert
.
Equal
(
t
,
"call_1"
,
anth
.
Content
[
1
]
.
ID
)
assert
.
Equal
(
t
,
"call_1"
,
anth
.
Content
[
1
]
.
ID
)
assert
.
Equal
(
t
,
"get_weather"
,
anth
.
Content
[
1
]
.
Name
)
assert
.
Equal
(
t
,
"get_weather"
,
anth
.
Content
[
1
]
.
Name
)
assert
.
JSONEq
(
t
,
`{"city":"NYC"}`
,
string
(
anth
.
Content
[
1
]
.
Input
))
}
func
TestResponsesToAnthropic_ReadToolDropsEmptyPages
(
t
*
testing
.
T
)
{
resp
:=
&
ResponsesResponse
{
ID
:
"resp_read"
,
Model
:
"gpt-5.5"
,
Status
:
"completed"
,
Output
:
[]
ResponsesOutput
{
{
Type
:
"function_call"
,
CallID
:
"call_read"
,
Name
:
"Read"
,
Arguments
:
`{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`
,
},
},
}
anth
:=
ResponsesToAnthropic
(
resp
,
"claude-opus-4-6"
)
require
.
Len
(
t
,
anth
.
Content
,
1
)
assert
.
Equal
(
t
,
"tool_use"
,
anth
.
Content
[
0
]
.
Type
)
assert
.
JSONEq
(
t
,
`{"file_path":"/tmp/demo.py","limit":2000,"offset":0}`
,
string
(
anth
.
Content
[
0
]
.
Input
))
}
func
TestResponsesToAnthropic_PreservesEmptyStringsForOtherTools
(
t
*
testing
.
T
)
{
resp
:=
&
ResponsesResponse
{
ID
:
"resp_other"
,
Model
:
"gpt-5.5"
,
Status
:
"completed"
,
Output
:
[]
ResponsesOutput
{
{
Type
:
"function_call"
,
CallID
:
"call_other"
,
Name
:
"Search"
,
Arguments
:
`{"query":""}`
,
},
},
}
anth
:=
ResponsesToAnthropic
(
resp
,
"claude-opus-4-6"
)
require
.
Len
(
t
,
anth
.
Content
,
1
)
assert
.
JSONEq
(
t
,
`{"query":""}`
,
string
(
anth
.
Content
[
0
]
.
Input
))
}
}
func
TestResponsesToAnthropic_Reasoning
(
t
*
testing
.
T
)
{
func
TestResponsesToAnthropic_Reasoning
(
t
*
testing
.
T
)
{
...
@@ -472,6 +514,41 @@ func TestStreamingToolCall(t *testing.T) {
...
@@ -472,6 +514,41 @@ func TestStreamingToolCall(t *testing.T) {
assert
.
Equal
(
t
,
"tool_use"
,
events
[
0
]
.
Delta
.
StopReason
)
assert
.
Equal
(
t
,
"tool_use"
,
events
[
0
]
.
Delta
.
StopReason
)
}
}
func
TestStreamingReadToolDropsEmptyPages
(
t
*
testing
.
T
)
{
state
:=
NewResponsesEventToAnthropicState
()
ResponsesEventToAnthropicEvents
(
&
ResponsesStreamEvent
{
Type
:
"response.created"
,
Response
:
&
ResponsesResponse
{
ID
:
"resp_read_stream"
,
Model
:
"gpt-5.5"
},
},
state
)
events
:=
ResponsesEventToAnthropicEvents
(
&
ResponsesStreamEvent
{
Type
:
"response.output_item.added"
,
OutputIndex
:
0
,
Item
:
&
ResponsesOutput
{
Type
:
"function_call"
,
CallID
:
"call_read"
,
Name
:
"Read"
},
},
state
)
require
.
Len
(
t
,
events
,
1
)
assert
.
Equal
(
t
,
"content_block_start"
,
events
[
0
]
.
Type
)
events
=
ResponsesEventToAnthropicEvents
(
&
ResponsesStreamEvent
{
Type
:
"response.function_call_arguments.delta"
,
OutputIndex
:
0
,
Delta
:
`{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`
,
},
state
)
assert
.
Len
(
t
,
events
,
0
)
events
=
ResponsesEventToAnthropicEvents
(
&
ResponsesStreamEvent
{
Type
:
"response.function_call_arguments.done"
,
OutputIndex
:
0
,
Arguments
:
`{"file_path":"/tmp/demo.py","limit":2000,"offset":0,"pages":""}`
,
},
state
)
require
.
Len
(
t
,
events
,
2
)
assert
.
Equal
(
t
,
"content_block_delta"
,
events
[
0
]
.
Type
)
assert
.
Equal
(
t
,
"input_json_delta"
,
events
[
0
]
.
Delta
.
Type
)
assert
.
JSONEq
(
t
,
`{"file_path":"/tmp/demo.py","limit":2000,"offset":0}`
,
events
[
0
]
.
Delta
.
PartialJSON
)
assert
.
Equal
(
t
,
"content_block_stop"
,
events
[
1
]
.
Type
)
}
func
TestStreamingReasoning
(
t
*
testing
.
T
)
{
func
TestStreamingReasoning
(
t
*
testing
.
T
)
{
state
:=
NewResponsesEventToAnthropicState
()
state
:=
NewResponsesEventToAnthropicState
()
...
...
backend/internal/pkg/apicompat/responses_to_anthropic.go
View file @
0537a490
...
@@ -52,7 +52,7 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo
...
@@ -52,7 +52,7 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo
Type
:
"tool_use"
,
Type
:
"tool_use"
,
ID
:
fromResponsesCallID
(
item
.
CallID
),
ID
:
fromResponsesCallID
(
item
.
CallID
),
Name
:
item
.
Name
,
Name
:
item
.
Name
,
Input
:
json
.
RawMessage
(
item
.
Arguments
),
Input
:
sanitizeAnthropicToolUseInput
(
item
.
Name
,
item
.
Arguments
),
})
})
case
"web_search_call"
:
case
"web_search_call"
:
toolUseID
:=
"srvtoolu_"
+
item
.
ID
toolUseID
:=
"srvtoolu_"
+
item
.
ID
...
@@ -129,6 +129,28 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
...
@@ -129,6 +129,28 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
}
}
}
}
func
sanitizeAnthropicToolUseInput
(
name
string
,
raw
string
)
json
.
RawMessage
{
if
name
!=
"Read"
||
raw
==
""
{
return
json
.
RawMessage
(
raw
)
}
var
input
map
[
string
]
json
.
RawMessage
if
err
:=
json
.
Unmarshal
([]
byte
(
raw
),
&
input
);
err
!=
nil
{
return
json
.
RawMessage
(
raw
)
}
if
pages
,
ok
:=
input
[
"pages"
];
!
ok
||
string
(
pages
)
!=
`""`
{
return
json
.
RawMessage
(
raw
)
}
delete
(
input
,
"pages"
)
sanitized
,
err
:=
json
.
Marshal
(
input
)
if
err
!=
nil
{
return
json
.
RawMessage
(
raw
)
}
return
sanitized
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
...
@@ -142,6 +164,8 @@ type ResponsesEventToAnthropicState struct {
...
@@ -142,6 +164,8 @@ type ResponsesEventToAnthropicState struct {
ContentBlockIndex
int
ContentBlockIndex
int
ContentBlockOpen
bool
ContentBlockOpen
bool
CurrentBlockType
string
// "text" | "thinking" | "tool_use"
CurrentBlockType
string
// "text" | "thinking" | "tool_use"
CurrentToolName
string
CurrentToolArgs
string
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
OutputIndexToBlockIdx
map
[
int
]
int
OutputIndexToBlockIdx
map
[
int
]
int
...
@@ -181,7 +205,7 @@ func ResponsesEventToAnthropicEvents(
...
@@ -181,7 +205,7 @@ func ResponsesEventToAnthropicEvents(
case
"response.function_call_arguments.delta"
:
case
"response.function_call_arguments.delta"
:
return
resToAnthHandleFuncArgsDelta
(
evt
,
state
)
return
resToAnthHandleFuncArgsDelta
(
evt
,
state
)
case
"response.function_call_arguments.done"
:
case
"response.function_call_arguments.done"
:
return
resToAnthHandle
BlockDone
(
state
)
return
resToAnthHandle
FuncArgsDone
(
evt
,
state
)
case
"response.output_item.done"
:
case
"response.output_item.done"
:
return
resToAnthHandleOutputItemDone
(
evt
,
state
)
return
resToAnthHandleOutputItemDone
(
evt
,
state
)
case
"response.reasoning_summary_text.delta"
:
case
"response.reasoning_summary_text.delta"
:
...
@@ -278,6 +302,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
...
@@ -278,6 +302,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
state
.
OutputIndexToBlockIdx
[
evt
.
OutputIndex
]
=
idx
state
.
OutputIndexToBlockIdx
[
evt
.
OutputIndex
]
=
idx
state
.
ContentBlockOpen
=
true
state
.
ContentBlockOpen
=
true
state
.
CurrentBlockType
=
"tool_use"
state
.
CurrentBlockType
=
"tool_use"
state
.
CurrentToolName
=
evt
.
Item
.
Name
state
.
CurrentToolArgs
=
""
events
=
append
(
events
,
AnthropicStreamEvent
{
events
=
append
(
events
,
AnthropicStreamEvent
{
Type
:
"content_block_start"
,
Type
:
"content_block_start"
,
...
@@ -358,6 +384,11 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
...
@@ -358,6 +384,11 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
return
nil
return
nil
}
}
if
state
.
CurrentBlockType
==
"tool_use"
&&
state
.
CurrentToolName
==
"Read"
{
state
.
CurrentToolArgs
+=
evt
.
Delta
return
nil
}
blockIdx
,
ok
:=
state
.
OutputIndexToBlockIdx
[
evt
.
OutputIndex
]
blockIdx
,
ok
:=
state
.
OutputIndexToBlockIdx
[
evt
.
OutputIndex
]
if
!
ok
{
if
!
ok
{
return
nil
return
nil
...
@@ -373,6 +404,33 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
...
@@ -373,6 +404,33 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
}}
}}
}
}
func
resToAnthHandleFuncArgsDone
(
evt
*
ResponsesStreamEvent
,
state
*
ResponsesEventToAnthropicState
)
[]
AnthropicStreamEvent
{
if
state
.
CurrentBlockType
!=
"tool_use"
||
state
.
CurrentToolName
!=
"Read"
{
return
resToAnthHandleBlockDone
(
state
)
}
raw
:=
evt
.
Arguments
if
raw
==
""
{
raw
=
state
.
CurrentToolArgs
}
sanitized
:=
sanitizeAnthropicToolUseInput
(
state
.
CurrentToolName
,
raw
)
if
len
(
sanitized
)
==
0
{
return
closeCurrentBlock
(
state
)
}
idx
:=
state
.
ContentBlockIndex
events
:=
[]
AnthropicStreamEvent
{{
Type
:
"content_block_delta"
,
Index
:
&
idx
,
Delta
:
&
AnthropicDelta
{
Type
:
"input_json_delta"
,
PartialJSON
:
string
(
sanitized
),
},
}}
events
=
append
(
events
,
closeCurrentBlock
(
state
)
...
)
return
events
}
func
resToAnthHandleReasoningDelta
(
evt
*
ResponsesStreamEvent
,
state
*
ResponsesEventToAnthropicState
)
[]
AnthropicStreamEvent
{
func
resToAnthHandleReasoningDelta
(
evt
*
ResponsesStreamEvent
,
state
*
ResponsesEventToAnthropicState
)
[]
AnthropicStreamEvent
{
if
evt
.
Delta
==
""
{
if
evt
.
Delta
==
""
{
return
nil
return
nil
...
@@ -524,6 +582,8 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE
...
@@ -524,6 +582,8 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE
idx
:=
state
.
ContentBlockIndex
idx
:=
state
.
ContentBlockIndex
state
.
ContentBlockOpen
=
false
state
.
ContentBlockOpen
=
false
state
.
ContentBlockIndex
++
state
.
ContentBlockIndex
++
state
.
CurrentToolName
=
""
state
.
CurrentToolArgs
=
""
return
[]
AnthropicStreamEvent
{{
return
[]
AnthropicStreamEvent
{{
Type
:
"content_block_stop"
,
Type
:
"content_block_stop"
,
Index
:
&
idx
,
Index
:
&
idx
,
...
...
backend/internal/service/openai_gateway_service.go
View file @
0537a490
...
@@ -1125,6 +1125,35 @@ func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) str
...
@@ -1125,6 +1125,35 @@ func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) str
return
sessionID
return
sessionID
}
}
func
explicitOpenAISessionID
(
c
*
gin
.
Context
,
body
[]
byte
)
string
{
if
c
==
nil
{
return
""
}
sessionID
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"session_id"
))
if
sessionID
==
""
{
sessionID
=
strings
.
TrimSpace
(
c
.
GetHeader
(
"conversation_id"
))
}
if
sessionID
==
""
&&
len
(
body
)
>
0
{
sessionID
=
strings
.
TrimSpace
(
gjson
.
GetBytes
(
body
,
"prompt_cache_key"
)
.
String
())
}
return
sessionID
}
// GenerateExplicitSessionHash generates a sticky-session hash only from explicit
// client session signals. It intentionally skips content-derived fallback and is
// used by stateless endpoints such as /v1/images.
func
(
s
*
OpenAIGatewayService
)
GenerateExplicitSessionHash
(
c
*
gin
.
Context
,
body
[]
byte
)
string
{
sessionID
:=
explicitOpenAISessionID
(
c
,
body
)
if
sessionID
==
""
{
return
""
}
currentHash
,
legacyHash
:=
deriveOpenAISessionHashes
(
sessionID
)
attachOpenAILegacySessionHashToGin
(
c
,
legacyHash
)
return
currentHash
}
// GenerateSessionHash generates a sticky-session hash for OpenAI requests.
// GenerateSessionHash generates a sticky-session hash for OpenAI requests.
//
//
// Priority:
// Priority:
...
@@ -1137,13 +1166,7 @@ func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte)
...
@@ -1137,13 +1166,7 @@ func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte)
return
""
return
""
}
}
sessionID
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"session_id"
))
sessionID
:=
explicitOpenAISessionID
(
c
,
body
)
if
sessionID
==
""
{
sessionID
=
strings
.
TrimSpace
(
c
.
GetHeader
(
"conversation_id"
))
}
if
sessionID
==
""
&&
len
(
body
)
>
0
{
sessionID
=
strings
.
TrimSpace
(
gjson
.
GetBytes
(
body
,
"prompt_cache_key"
)
.
String
())
}
if
sessionID
==
""
&&
len
(
body
)
>
0
{
if
sessionID
==
""
&&
len
(
body
)
>
0
{
sessionID
=
deriveOpenAIContentSessionSeed
(
body
)
sessionID
=
deriveOpenAIContentSessionSeed
(
body
)
}
}
...
...
backend/internal/service/openai_gateway_service_test.go
View file @
0537a490
...
@@ -227,6 +227,41 @@ func TestOpenAIGatewayService_GenerateSessionHash_AttachesLegacyHashToContext(t
...
@@ -227,6 +227,41 @@ func TestOpenAIGatewayService_GenerateSessionHash_AttachesLegacyHashToContext(t
require
.
NotEmpty
(
t
,
openAILegacySessionHashFromContext
(
c
.
Request
.
Context
()))
require
.
NotEmpty
(
t
,
openAILegacySessionHashFromContext
(
c
.
Request
.
Context
()))
}
}
func
TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
svc
:=
&
OpenAIGatewayService
{}
body
:=
[]
byte
(
`{"model":"gpt-image-2","prompt":"draw a cat"}`
)
t
.
Run
(
"stateless image body stays unstuck"
,
func
(
t
*
testing
.
T
)
{
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
nil
)
require
.
Empty
(
t
,
svc
.
GenerateExplicitSessionHash
(
c
,
body
))
require
.
Empty
(
t
,
openAILegacySessionHashFromContext
(
c
.
Request
.
Context
()))
})
t
.
Run
(
"prompt_cache_key is explicit"
,
func
(
t
*
testing
.
T
)
{
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
nil
)
got
:=
svc
.
GenerateExplicitSessionHash
(
c
,
[]
byte
(
`{"model":"gpt-image-2","prompt_cache_key":"image-session"}`
))
require
.
Equal
(
t
,
fmt
.
Sprintf
(
"%016x"
,
xxhash
.
Sum64String
(
"image-session"
)),
got
)
require
.
NotEmpty
(
t
,
openAILegacySessionHashFromContext
(
c
.
Request
.
Context
()))
})
t
.
Run
(
"header overrides body"
,
func
(
t
*
testing
.
T
)
{
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
nil
)
c
.
Request
.
Header
.
Set
(
"session_id"
,
"header-session"
)
got
:=
svc
.
GenerateExplicitSessionHash
(
c
,
[]
byte
(
`{"prompt_cache_key":"body-session"}`
))
require
.
Equal
(
t
,
fmt
.
Sprintf
(
"%016x"
,
xxhash
.
Sum64String
(
"header-session"
)),
got
)
})
}
func
TestOpenAIGatewayService_GenerateSessionHashWithFallback
(
t
*
testing
.
T
)
{
func
TestOpenAIGatewayService_GenerateSessionHashWithFallback
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
rec
:=
httptest
.
NewRecorder
()
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment