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
2d89f366
Commit
2d89f366
authored
Dec 26, 2025
by
shaw
Browse files
Merge PR #42: fix(sse): 修复非标准 SSE 格式解析问题
parents
3d608c26
16eec4eb
Changes
3
Show whitespace changes
Inline
Side-by-side
backend/internal/service/account_test_service.go
View file @
2d89f366
...
@@ -10,6 +10,7 @@ import (
...
@@ -10,6 +10,7 @@ import (
"io"
"io"
"log"
"log"
"net/http"
"net/http"
"regexp"
"strconv"
"strconv"
"strings"
"strings"
"time"
"time"
...
@@ -20,6 +21,10 @@ import (
...
@@ -20,6 +21,10 @@ import (
"github.com/google/uuid"
"github.com/google/uuid"
)
)
// sseDataPrefix matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var
sseDataPrefix
=
regexp
.
MustCompile
(
`^data:\s*`
)
const
(
const
(
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
testOpenAIAPIURL
=
"https://api.openai.com/v1/responses"
testOpenAIAPIURL
=
"https://api.openai.com/v1/responses"
...
@@ -411,11 +416,11 @@ func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader)
...
@@ -411,11 +416,11 @@ func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader)
}
}
line
=
strings
.
TrimSpace
(
line
)
line
=
strings
.
TrimSpace
(
line
)
if
line
==
""
||
!
s
trings
.
HasPrefix
(
line
,
"data: "
)
{
if
line
==
""
||
!
s
seDataPrefix
.
MatchString
(
line
)
{
continue
continue
}
}
jsonStr
:=
s
trings
.
TrimPrefix
(
line
,
"
data:
"
)
jsonStr
:=
s
seDataPrefix
.
ReplaceAllString
(
line
,
""
)
if
jsonStr
==
"[DONE]"
{
if
jsonStr
==
"[DONE]"
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
return
nil
...
@@ -465,11 +470,11 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
...
@@ -465,11 +470,11 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
}
}
line
=
strings
.
TrimSpace
(
line
)
line
=
strings
.
TrimSpace
(
line
)
if
line
==
""
||
!
s
trings
.
HasPrefix
(
line
,
"data: "
)
{
if
line
==
""
||
!
s
seDataPrefix
.
MatchString
(
line
)
{
continue
continue
}
}
jsonStr
:=
s
trings
.
TrimPrefix
(
line
,
"
data:
"
)
jsonStr
:=
s
seDataPrefix
.
ReplaceAllString
(
line
,
""
)
if
jsonStr
==
"[DONE]"
{
if
jsonStr
==
"[DONE]"
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
return
nil
...
...
backend/internal/service/gateway_service.go
View file @
2d89f366
...
@@ -30,6 +30,10 @@ const (
...
@@ -30,6 +30,10 @@ const (
stickySessionTTL
=
time
.
Hour
// 粘性会话TTL
stickySessionTTL
=
time
.
Hour
// 粘性会话TTL
)
)
// sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var
sseDataRe
=
regexp
.
MustCompile
(
`^data:\s*`
)
// allowedHeaders 白名单headers(参考CRS项目)
// allowedHeaders 白名单headers(参考CRS项目)
var
allowedHeaders
=
map
[
string
]
bool
{
var
allowedHeaders
=
map
[
string
]
bool
{
"accept"
:
true
,
"accept"
:
true
,
...
@@ -745,8 +749,12 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
...
@@ -745,8 +749,12 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
for
scanner
.
Scan
()
{
for
scanner
.
Scan
()
{
line
:=
scanner
.
Text
()
line
:=
scanner
.
Text
()
// Extract data from SSE line (supports both "data: " and "data:" formats)
if
sseDataRe
.
MatchString
(
line
)
{
data
:=
sseDataRe
.
ReplaceAllString
(
line
,
""
)
// 如果有模型映射,替换响应中的model字段
// 如果有模型映射,替换响应中的model字段
if
needModelReplace
&&
strings
.
HasPrefix
(
line
,
"data: "
)
{
if
needModelReplace
{
line
=
s
.
replaceModelInSSELine
(
line
,
mappedModel
,
originalModel
)
line
=
s
.
replaceModelInSSELine
(
line
,
mappedModel
,
originalModel
)
}
}
...
@@ -756,15 +764,18 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
...
@@ -756,15 +764,18 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
}
flusher
.
Flush
()
flusher
.
Flush
()
// 解析usage数据
if
strings
.
HasPrefix
(
line
,
"data: "
)
{
data
:=
line
[
6
:
]
// 记录首字时间:第一个有效的 content_block_delta 或 message_start
// 记录首字时间:第一个有效的 content_block_delta 或 message_start
if
firstTokenMs
==
nil
&&
data
!=
""
&&
data
!=
"[DONE]"
{
if
firstTokenMs
==
nil
&&
data
!=
""
&&
data
!=
"[DONE]"
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
firstTokenMs
=
&
ms
}
}
s
.
parseSSEUsage
(
data
,
usage
)
s
.
parseSSEUsage
(
data
,
usage
)
}
else
{
// 非 data 行直接转发
if
_
,
err
:=
fmt
.
Fprintf
(
w
,
"%s
\n
"
,
line
);
err
!=
nil
{
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
err
}
flusher
.
Flush
()
}
}
}
}
...
@@ -777,7 +788,10 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
...
@@ -777,7 +788,10 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
// replaceModelInSSELine 替换SSE数据行中的model字段
// replaceModelInSSELine 替换SSE数据行中的model字段
func
(
s
*
GatewayService
)
replaceModelInSSELine
(
line
,
fromModel
,
toModel
string
)
string
{
func
(
s
*
GatewayService
)
replaceModelInSSELine
(
line
,
fromModel
,
toModel
string
)
string
{
data
:=
line
[
6
:
]
// 去掉 "data: " 前缀
if
!
sseDataRe
.
MatchString
(
line
)
{
return
line
}
data
:=
sseDataRe
.
ReplaceAllString
(
line
,
""
)
if
data
==
""
||
data
==
"[DONE]"
{
if
data
==
""
||
data
==
"[DONE]"
{
return
line
return
line
}
}
...
...
backend/internal/service/openai_gateway_service.go
View file @
2d89f366
...
@@ -11,6 +11,7 @@ import (
...
@@ -11,6 +11,7 @@ import (
"fmt"
"fmt"
"io"
"io"
"net/http"
"net/http"
"regexp"
"strconv"
"strconv"
"strings"
"strings"
"time"
"time"
...
@@ -27,6 +28,10 @@ const (
...
@@ -27,6 +28,10 @@ const (
openaiStickySessionTTL
=
time
.
Hour
// 粘性会话TTL
openaiStickySessionTTL
=
time
.
Hour
// 粘性会话TTL
)
)
// openaiSSEDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var
openaiSSEDataRe
=
regexp
.
MustCompile
(
`^data:\s*`
)
// OpenAI allowed headers whitelist (for non-OAuth accounts)
// OpenAI allowed headers whitelist (for non-OAuth accounts)
var
openaiAllowedHeaders
=
map
[
string
]
bool
{
var
openaiAllowedHeaders
=
map
[
string
]
bool
{
"accept-language"
:
true
,
"accept-language"
:
true
,
...
@@ -463,8 +468,12 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
...
@@ -463,8 +468,12 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
for
scanner
.
Scan
()
{
for
scanner
.
Scan
()
{
line
:=
scanner
.
Text
()
line
:=
scanner
.
Text
()
// Extract data from SSE line (supports both "data: " and "data:" formats)
if
openaiSSEDataRe
.
MatchString
(
line
)
{
data
:=
openaiSSEDataRe
.
ReplaceAllString
(
line
,
""
)
// Replace model in response if needed
// Replace model in response if needed
if
needModelReplace
&&
strings
.
HasPrefix
(
line
,
"data: "
)
{
if
needModelReplace
{
line
=
s
.
replaceModelInSSELine
(
line
,
mappedModel
,
originalModel
)
line
=
s
.
replaceModelInSSELine
(
line
,
mappedModel
,
originalModel
)
}
}
...
@@ -474,15 +483,18 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
...
@@ -474,15 +483,18 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
}
}
flusher
.
Flush
()
flusher
.
Flush
()
// Parse usage data
if
strings
.
HasPrefix
(
line
,
"data: "
)
{
data
:=
line
[
6
:
]
// Record first token time
// Record first token time
if
firstTokenMs
==
nil
&&
data
!=
""
&&
data
!=
"[DONE]"
{
if
firstTokenMs
==
nil
&&
data
!=
""
&&
data
!=
"[DONE]"
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
firstTokenMs
=
&
ms
}
}
s
.
parseSSEUsage
(
data
,
usage
)
s
.
parseSSEUsage
(
data
,
usage
)
}
else
{
// Forward non-data lines as-is
if
_
,
err
:=
fmt
.
Fprintf
(
w
,
"%s
\n
"
,
line
);
err
!=
nil
{
return
&
openaiStreamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
err
}
flusher
.
Flush
()
}
}
}
}
...
@@ -494,7 +506,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
...
@@ -494,7 +506,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
}
}
func
(
s
*
OpenAIGatewayService
)
replaceModelInSSELine
(
line
,
fromModel
,
toModel
string
)
string
{
func
(
s
*
OpenAIGatewayService
)
replaceModelInSSELine
(
line
,
fromModel
,
toModel
string
)
string
{
data
:=
line
[
6
:
]
if
!
openaiSSEDataRe
.
MatchString
(
line
)
{
return
line
}
data
:=
openaiSSEDataRe
.
ReplaceAllString
(
line
,
""
)
if
data
==
""
||
data
==
"[DONE]"
{
if
data
==
""
||
data
==
"[DONE]"
{
return
line
return
line
}
}
...
...
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