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
1108 lines
32 KiB
Go
1108 lines
32 KiB
Go
package openai_ws_v2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
coderws "github.com/coder/websocket"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
type FrameConn interface {
|
|
ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error)
|
|
WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error
|
|
Close() error
|
|
}
|
|
|
|
type Usage struct {
|
|
InputTokens int
|
|
OutputTokens int
|
|
CacheCreationInputTokens int
|
|
CacheReadInputTokens int
|
|
ImageOutputTokens int
|
|
}
|
|
|
|
type RelayResult struct {
|
|
RequestModel string
|
|
ResponseModel string
|
|
ResponseModelConflict bool
|
|
Usage Usage
|
|
RequestID string
|
|
TerminalEventType string
|
|
FirstTokenMs *int
|
|
Duration time.Duration
|
|
ClientToUpstreamFrames int64
|
|
UpstreamToClientFrames int64
|
|
DroppedDownstreamFrames int64
|
|
}
|
|
|
|
type RelayTurnResult struct {
|
|
RequestModel string
|
|
ResponseModel string
|
|
ResponseModelConflict bool
|
|
Usage Usage
|
|
RequestID string
|
|
TerminalEventType string
|
|
StartedAt time.Time
|
|
Duration time.Duration
|
|
FirstTokenMs *int
|
|
}
|
|
|
|
type RelayExit struct {
|
|
Stage string
|
|
Err error
|
|
Graceful bool
|
|
WroteDownstream bool
|
|
}
|
|
|
|
type RelayOptions struct {
|
|
WriteTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
UpstreamDrainTimeout time.Duration
|
|
FirstTurnStartedAt time.Time
|
|
TakeNextTurnStartedAt func() time.Time
|
|
FirstMessageType coderws.MessageType
|
|
FirstMessageSent bool
|
|
StartClientAfterFirstDownstream bool
|
|
OnUsageParseFailure func(eventType string, usageRaw string)
|
|
OnTurnComplete func(turn RelayTurnResult)
|
|
BeforeWriteClient func(msgType coderws.MessageType, payload []byte, wroteDownstream bool) error
|
|
BeforeClientWrite func(msgType coderws.MessageType, payload []byte)
|
|
AfterClientWrite func(msgType coderws.MessageType, payload []byte, writeErr error)
|
|
BeforeRelayCancel func(exit RelayExit)
|
|
ReadClientFrame func(ctx context.Context, clientConn FrameConn) (coderws.MessageType, []byte, error)
|
|
OnTrace func(event RelayTraceEvent)
|
|
Now func() time.Time
|
|
}
|
|
|
|
type RelayTraceEvent struct {
|
|
Stage string
|
|
Direction string
|
|
MessageType string
|
|
PayloadBytes int
|
|
Graceful bool
|
|
WroteDownstream bool
|
|
Error string
|
|
}
|
|
|
|
type relayState struct {
|
|
usage Usage
|
|
requestModelMu sync.RWMutex
|
|
requestModel string
|
|
pendingTurnStart atomic.Pointer[time.Time]
|
|
lastResponseID string
|
|
lastResponseModel string
|
|
responseConflict bool
|
|
terminalEventType string
|
|
firstTokenMs *int
|
|
turnTimingByID map[string]*relayTurnTiming
|
|
activeTurn *relayTurnTiming
|
|
}
|
|
|
|
type relayExitSignal struct {
|
|
stage string
|
|
err error
|
|
graceful bool
|
|
wroteDownstream bool
|
|
}
|
|
|
|
type observedUpstreamEvent struct {
|
|
terminal bool
|
|
eventType string
|
|
responseID string
|
|
usage Usage
|
|
startedAt time.Time
|
|
responseModel string
|
|
responseConflict bool
|
|
duration time.Duration
|
|
firstToken *int
|
|
}
|
|
|
|
type relayTurnTiming struct {
|
|
startAt time.Time
|
|
firstTokenMs *int
|
|
firstResponseModel string
|
|
terminalResponseModel string
|
|
responseModelConflict bool
|
|
}
|
|
|
|
func Relay(
|
|
ctx context.Context,
|
|
clientConn FrameConn,
|
|
upstreamConn FrameConn,
|
|
firstClientMessage []byte,
|
|
options RelayOptions,
|
|
) (RelayResult, *RelayExit) {
|
|
result := RelayResult{RequestModel: strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String())}
|
|
if clientConn == nil || upstreamConn == nil {
|
|
return result, &RelayExit{Stage: "relay_init", Err: errors.New("relay connection is nil")}
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
nowFn := options.Now
|
|
if nowFn == nil {
|
|
nowFn = time.Now
|
|
}
|
|
writeTimeout := options.WriteTimeout
|
|
if writeTimeout <= 0 {
|
|
writeTimeout = 2 * time.Minute
|
|
}
|
|
drainTimeout := options.UpstreamDrainTimeout
|
|
if drainTimeout <= 0 {
|
|
drainTimeout = 1200 * time.Millisecond
|
|
}
|
|
firstMessageType := options.FirstMessageType
|
|
if firstMessageType != coderws.MessageBinary {
|
|
firstMessageType = coderws.MessageText
|
|
}
|
|
startAt := nowFn()
|
|
state := &relayState{requestModel: result.RequestModel}
|
|
if isClientResponseCreateFrame(firstMessageType, firstClientMessage) {
|
|
firstTurnStartedAt := options.FirstTurnStartedAt
|
|
if firstTurnStartedAt.IsZero() {
|
|
firstTurnStartedAt = startAt
|
|
}
|
|
state.setPendingTurnStartedAt(firstTurnStartedAt)
|
|
}
|
|
onTrace := options.OnTrace
|
|
|
|
relayCtx, relayCancel := context.WithCancel(ctx)
|
|
defer relayCancel()
|
|
|
|
lastActivity := atomic.Int64{}
|
|
lastActivity.Store(nowFn().UnixNano())
|
|
markActivity := func() {
|
|
lastActivity.Store(nowFn().UnixNano())
|
|
}
|
|
|
|
writeUpstream := func(msgType coderws.MessageType, payload []byte) error {
|
|
writeCtx, cancel := context.WithTimeout(relayCtx, writeTimeout)
|
|
defer cancel()
|
|
return upstreamConn.WriteFrame(writeCtx, msgType, payload)
|
|
}
|
|
writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error {
|
|
if isClientResponseCreateFrame(msgType, payload) {
|
|
state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
|
|
turnStartedAt := time.Time{}
|
|
if options.TakeNextTurnStartedAt != nil {
|
|
turnStartedAt = options.TakeNextTurnStartedAt()
|
|
}
|
|
if turnStartedAt.IsZero() {
|
|
turnStartedAt = nowFn()
|
|
}
|
|
state.setPendingTurnStartedAt(turnStartedAt)
|
|
}
|
|
return writeUpstream(msgType, payload)
|
|
}
|
|
writeClient := func(msgType coderws.MessageType, payload []byte) error {
|
|
// 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write
|
|
// ctx 被取消时会直接硬关连接(context.AfterFunc 的 stop 不等待执行中
|
|
// 的回调),外部取消若落在一次已成功写入的解除武装窗口内,会连同尚未
|
|
// 发出的 close 帧一起冲掉,客户端只能看到裸 EOF 而收不到关闭码。与读
|
|
// 侧 conn.Read(context.Background()) 同理,取消路径的连接回收由各退出
|
|
// 分支的显式 Close/CloseNow 兜底。
|
|
writeCtx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
|
defer cancel()
|
|
return clientConn.WriteFrame(writeCtx, msgType, payload)
|
|
}
|
|
|
|
clientToUpstreamFrames := &atomic.Int64{}
|
|
upstreamToClientFrames := &atomic.Int64{}
|
|
droppedDownstreamFrames := &atomic.Int64{}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_start",
|
|
PayloadBytes: len(firstClientMessage),
|
|
MessageType: relayMessageTypeString(firstMessageType),
|
|
})
|
|
|
|
if options.FirstMessageSent {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "write_first_message_skipped",
|
|
Direction: "client_to_upstream",
|
|
MessageType: relayMessageTypeString(firstMessageType),
|
|
PayloadBytes: len(firstClientMessage),
|
|
})
|
|
} else {
|
|
if err := writeUpstream(firstMessageType, firstClientMessage); err != nil {
|
|
result.Duration = nowFn().Sub(startAt)
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "write_first_message_failed",
|
|
Direction: "client_to_upstream",
|
|
MessageType: relayMessageTypeString(firstMessageType),
|
|
PayloadBytes: len(firstClientMessage),
|
|
Error: err.Error(),
|
|
})
|
|
return result, &RelayExit{Stage: "write_upstream", Err: err}
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "write_first_message_ok",
|
|
Direction: "client_to_upstream",
|
|
MessageType: relayMessageTypeString(firstMessageType),
|
|
PayloadBytes: len(firstClientMessage),
|
|
})
|
|
}
|
|
clientToUpstreamFrames.Add(1)
|
|
markActivity()
|
|
|
|
exitCh := make(chan relayExitSignal, 3)
|
|
dropDownstreamWrites := atomic.Bool{}
|
|
clientReaderStarted := atomic.Bool{}
|
|
startClientReader := func() {
|
|
if !clientReaderStarted.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
go runClientToUpstream(relayCtx, clientConn, options.ReadClientFrame, writeClientFrameUpstream, markActivity, clientToUpstreamFrames, onTrace, exitCh)
|
|
}
|
|
if !options.StartClientAfterFirstDownstream {
|
|
startClientReader()
|
|
}
|
|
go runUpstreamToClient(
|
|
relayCtx,
|
|
upstreamConn,
|
|
writeClient,
|
|
startAt,
|
|
nowFn,
|
|
state,
|
|
options.OnUsageParseFailure,
|
|
options.OnTurnComplete,
|
|
options.BeforeWriteClient,
|
|
options.BeforeClientWrite,
|
|
options.AfterClientWrite,
|
|
func(msgType coderws.MessageType, payload []byte) {
|
|
if options.StartClientAfterFirstDownstream {
|
|
startClientReader()
|
|
}
|
|
},
|
|
&dropDownstreamWrites,
|
|
upstreamToClientFrames,
|
|
droppedDownstreamFrames,
|
|
markActivity,
|
|
onTrace,
|
|
exitCh,
|
|
)
|
|
go runIdleWatchdog(relayCtx, nowFn, options.IdleTimeout, &lastActivity, onTrace, exitCh)
|
|
|
|
firstExit := <-exitCh
|
|
// An outer ingress cancellation is a control-plane close, not a graceful
|
|
// upstream disconnect. Leave the client connection open here so the
|
|
// adapter can emit the precise lease/request close code. Internal
|
|
// relayCancel does not cancel ctx and therefore does not take this path.
|
|
if ctx.Err() != nil {
|
|
firstExit.graceful = false
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "first_exit",
|
|
Direction: relayDirectionFromStage(firstExit.stage),
|
|
Graceful: firstExit.graceful,
|
|
WroteDownstream: firstExit.wroteDownstream,
|
|
Error: relayErrorString(firstExit.err),
|
|
})
|
|
if options.BeforeRelayCancel != nil {
|
|
options.BeforeRelayCancel(RelayExit{
|
|
Stage: firstExit.stage,
|
|
Err: firstExit.err,
|
|
Graceful: firstExit.graceful,
|
|
WroteDownstream: firstExit.wroteDownstream,
|
|
})
|
|
}
|
|
combinedWroteDownstream := firstExit.wroteDownstream
|
|
secondExit := relayExitSignal{graceful: true}
|
|
hasSecondExit := false
|
|
|
|
// 客户端断开后尽力继续读取上游短窗口,捕获延迟 usage/terminal 事件用于计费。
|
|
if firstExit.stage == "read_client" && firstExit.graceful {
|
|
dropDownstreamWrites.Store(true)
|
|
secondExit, hasSecondExit = waitRelayExit(exitCh, drainTimeout)
|
|
} else {
|
|
relayCancel()
|
|
_ = upstreamConn.Close()
|
|
if clientReaderStarted.Load() {
|
|
secondExit, hasSecondExit = waitRelayExit(exitCh, 200*time.Millisecond)
|
|
}
|
|
}
|
|
if hasSecondExit {
|
|
combinedWroteDownstream = combinedWroteDownstream || secondExit.wroteDownstream
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "second_exit",
|
|
Direction: relayDirectionFromStage(secondExit.stage),
|
|
Graceful: secondExit.graceful,
|
|
WroteDownstream: secondExit.wroteDownstream,
|
|
Error: relayErrorString(secondExit.err),
|
|
})
|
|
}
|
|
|
|
relayCancel()
|
|
_ = upstreamConn.Close()
|
|
|
|
enrichResult(&result, state, nowFn().Sub(startAt))
|
|
result.ClientToUpstreamFrames = clientToUpstreamFrames.Load()
|
|
result.UpstreamToClientFrames = upstreamToClientFrames.Load()
|
|
result.DroppedDownstreamFrames = droppedDownstreamFrames.Load()
|
|
if options.FirstMessageSent && firstExit.stage == "read_client" && firstExit.graceful {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_client_closed",
|
|
Graceful: true,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
})
|
|
return result, nil
|
|
}
|
|
if firstExit.stage == "read_client" && firstExit.graceful {
|
|
stage := "client_disconnected"
|
|
exitErr := firstExit.err
|
|
if hasSecondExit && !secondExit.graceful {
|
|
stage = secondExit.stage
|
|
exitErr = secondExit.err
|
|
}
|
|
if exitErr == nil {
|
|
exitErr = io.EOF
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_exit",
|
|
Direction: relayDirectionFromStage(stage),
|
|
Graceful: false,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
Error: relayErrorString(exitErr),
|
|
})
|
|
return result, &RelayExit{
|
|
Stage: stage,
|
|
Err: exitErr,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
}
|
|
}
|
|
if firstExit.graceful && (!hasSecondExit || secondExit.graceful) {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_complete",
|
|
Graceful: true,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
})
|
|
_ = clientConn.Close()
|
|
return result, nil
|
|
}
|
|
if !firstExit.graceful {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_exit",
|
|
Direction: relayDirectionFromStage(firstExit.stage),
|
|
Graceful: false,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
Error: relayErrorString(firstExit.err),
|
|
})
|
|
return result, &RelayExit{
|
|
Stage: firstExit.stage,
|
|
Err: firstExit.err,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
}
|
|
}
|
|
if hasSecondExit && !secondExit.graceful {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_exit",
|
|
Direction: relayDirectionFromStage(secondExit.stage),
|
|
Graceful: false,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
Error: relayErrorString(secondExit.err),
|
|
})
|
|
return result, &RelayExit{
|
|
Stage: secondExit.stage,
|
|
Err: secondExit.err,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
}
|
|
}
|
|
if options.FirstMessageSent {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_client_closed",
|
|
Graceful: true,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
})
|
|
return result, nil
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "relay_complete",
|
|
Graceful: true,
|
|
WroteDownstream: combinedWroteDownstream,
|
|
})
|
|
_ = clientConn.Close()
|
|
return result, nil
|
|
}
|
|
|
|
func isClientResponseCreateFrame(msgType coderws.MessageType, payload []byte) bool {
|
|
if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create"
|
|
}
|
|
|
|
func runClientToUpstream(
|
|
ctx context.Context,
|
|
clientConn FrameConn,
|
|
readClientFrame func(context.Context, FrameConn) (coderws.MessageType, []byte, error),
|
|
writeUpstream func(msgType coderws.MessageType, payload []byte) error,
|
|
markActivity func(),
|
|
forwardedFrames *atomic.Int64,
|
|
onTrace func(event RelayTraceEvent),
|
|
exitCh chan<- relayExitSignal,
|
|
) {
|
|
if readClientFrame == nil {
|
|
readClientFrame = func(ctx context.Context, conn FrameConn) (coderws.MessageType, []byte, error) {
|
|
return conn.ReadFrame(ctx)
|
|
}
|
|
}
|
|
for {
|
|
msgType, payload, err := readClientFrame(ctx, clientConn)
|
|
if err != nil {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "read_client_failed",
|
|
Direction: "client_to_upstream",
|
|
Error: err.Error(),
|
|
Graceful: isDisconnectError(err),
|
|
})
|
|
exitCh <- relayExitSignal{stage: "read_client", err: err, graceful: isDisconnectError(err)}
|
|
return
|
|
}
|
|
markActivity()
|
|
if err := writeUpstream(msgType, payload); err != nil {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "write_upstream_failed",
|
|
Direction: "client_to_upstream",
|
|
MessageType: relayMessageTypeString(msgType),
|
|
PayloadBytes: len(payload),
|
|
Error: err.Error(),
|
|
})
|
|
exitCh <- relayExitSignal{stage: "write_upstream", err: err}
|
|
return
|
|
}
|
|
if forwardedFrames != nil {
|
|
forwardedFrames.Add(1)
|
|
}
|
|
markActivity()
|
|
}
|
|
}
|
|
|
|
func runUpstreamToClient(
|
|
ctx context.Context,
|
|
upstreamConn FrameConn,
|
|
writeClient func(msgType coderws.MessageType, payload []byte) error,
|
|
startAt time.Time,
|
|
nowFn func() time.Time,
|
|
state *relayState,
|
|
onUsageParseFailure func(eventType string, usageRaw string),
|
|
onTurnComplete func(turn RelayTurnResult),
|
|
beforeWriteClient func(msgType coderws.MessageType, payload []byte, wroteDownstream bool) error,
|
|
beforeClientWrite func(msgType coderws.MessageType, payload []byte),
|
|
afterClientWrite func(msgType coderws.MessageType, payload []byte, writeErr error),
|
|
afterWriteClient func(msgType coderws.MessageType, payload []byte),
|
|
dropDownstreamWrites *atomic.Bool,
|
|
forwardedFrames *atomic.Int64,
|
|
droppedFrames *atomic.Int64,
|
|
markActivity func(),
|
|
onTrace func(event RelayTraceEvent),
|
|
exitCh chan<- relayExitSignal,
|
|
) {
|
|
wroteDownstream := false
|
|
for {
|
|
msgType, payload, err := upstreamConn.ReadFrame(ctx)
|
|
if err != nil {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "read_upstream_failed",
|
|
Direction: "upstream_to_client",
|
|
Error: err.Error(),
|
|
Graceful: isDisconnectError(err),
|
|
WroteDownstream: wroteDownstream,
|
|
})
|
|
exitCh <- relayExitSignal{
|
|
stage: "read_upstream",
|
|
err: err,
|
|
graceful: isDisconnectError(err),
|
|
wroteDownstream: wroteDownstream,
|
|
}
|
|
return
|
|
}
|
|
markActivity()
|
|
if beforeWriteClient != nil {
|
|
if err := beforeWriteClient(msgType, payload, wroteDownstream); err != nil {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "upstream_message_rejected",
|
|
Direction: "upstream_to_client",
|
|
MessageType: relayMessageTypeString(msgType),
|
|
PayloadBytes: len(payload),
|
|
WroteDownstream: wroteDownstream,
|
|
Error: err.Error(),
|
|
})
|
|
exitCh <- relayExitSignal{
|
|
stage: "upstream_message",
|
|
err: err,
|
|
wroteDownstream: wroteDownstream,
|
|
}
|
|
return
|
|
}
|
|
}
|
|
observedEvent := observedUpstreamEvent{}
|
|
switch msgType {
|
|
case coderws.MessageText:
|
|
observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure)
|
|
case coderws.MessageBinary:
|
|
// binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。
|
|
}
|
|
emitTurnComplete(onTurnComplete, state, observedEvent)
|
|
if dropDownstreamWrites != nil && dropDownstreamWrites.Load() {
|
|
if droppedFrames != nil {
|
|
droppedFrames.Add(1)
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "drop_downstream_frame",
|
|
Direction: "upstream_to_client",
|
|
MessageType: relayMessageTypeString(msgType),
|
|
PayloadBytes: len(payload),
|
|
WroteDownstream: wroteDownstream,
|
|
})
|
|
if observedEvent.terminal {
|
|
exitCh <- relayExitSignal{
|
|
stage: "drain_terminal",
|
|
graceful: true,
|
|
wroteDownstream: wroteDownstream,
|
|
}
|
|
return
|
|
}
|
|
markActivity()
|
|
continue
|
|
}
|
|
if beforeClientWrite != nil {
|
|
beforeClientWrite(msgType, payload)
|
|
}
|
|
writeErr := writeClient(msgType, payload)
|
|
if afterClientWrite != nil {
|
|
afterClientWrite(msgType, payload, writeErr)
|
|
}
|
|
if writeErr != nil {
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "write_client_failed",
|
|
Direction: "upstream_to_client",
|
|
MessageType: relayMessageTypeString(msgType),
|
|
PayloadBytes: len(payload),
|
|
WroteDownstream: wroteDownstream,
|
|
Error: writeErr.Error(),
|
|
})
|
|
exitCh <- relayExitSignal{stage: "write_client", err: writeErr, wroteDownstream: wroteDownstream}
|
|
return
|
|
}
|
|
wroteDownstream = true
|
|
if afterWriteClient != nil {
|
|
afterWriteClient(msgType, payload)
|
|
}
|
|
if forwardedFrames != nil {
|
|
forwardedFrames.Add(1)
|
|
}
|
|
markActivity()
|
|
}
|
|
}
|
|
|
|
func runIdleWatchdog(
|
|
ctx context.Context,
|
|
nowFn func() time.Time,
|
|
idleTimeout time.Duration,
|
|
lastActivity *atomic.Int64,
|
|
onTrace func(event RelayTraceEvent),
|
|
exitCh chan<- relayExitSignal,
|
|
) {
|
|
if idleTimeout <= 0 {
|
|
return
|
|
}
|
|
checkInterval := minDuration(idleTimeout/4, 5*time.Second)
|
|
if checkInterval < time.Second {
|
|
checkInterval = time.Second
|
|
}
|
|
ticker := time.NewTicker(checkInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
last := time.Unix(0, lastActivity.Load())
|
|
if nowFn().Sub(last) < idleTimeout {
|
|
continue
|
|
}
|
|
emitRelayTrace(onTrace, RelayTraceEvent{
|
|
Stage: "idle_timeout_triggered",
|
|
Direction: "watchdog",
|
|
Error: context.DeadlineExceeded.Error(),
|
|
})
|
|
exitCh <- relayExitSignal{stage: "idle_timeout", err: context.DeadlineExceeded}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func emitRelayTrace(onTrace func(event RelayTraceEvent), event RelayTraceEvent) {
|
|
if onTrace == nil {
|
|
return
|
|
}
|
|
onTrace(event)
|
|
}
|
|
|
|
func relayMessageTypeString(msgType coderws.MessageType) string {
|
|
switch msgType {
|
|
case coderws.MessageText:
|
|
return "text"
|
|
case coderws.MessageBinary:
|
|
return "binary"
|
|
default:
|
|
return "unknown(" + strconv.Itoa(int(msgType)) + ")"
|
|
}
|
|
}
|
|
|
|
func relayDirectionFromStage(stage string) string {
|
|
switch stage {
|
|
case "read_client", "write_upstream":
|
|
return "client_to_upstream"
|
|
case "read_upstream", "write_client", "drain_terminal":
|
|
return "upstream_to_client"
|
|
case "idle_timeout":
|
|
return "watchdog"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func relayErrorString(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func observeUpstreamMessage(
|
|
state *relayState,
|
|
message []byte,
|
|
startAt time.Time,
|
|
nowFn func() time.Time,
|
|
onUsageParseFailure func(eventType string, usageRaw string),
|
|
) observedUpstreamEvent {
|
|
if state == nil || len(message) == 0 {
|
|
return observedUpstreamEvent{}
|
|
}
|
|
values := gjson.GetManyBytes(message, "type", "response.id", "response_id", "id")
|
|
eventType := strings.TrimSpace(values[0].String())
|
|
if eventType == "" {
|
|
return observedUpstreamEvent{}
|
|
}
|
|
responseID := strings.TrimSpace(values[1].String())
|
|
if responseID == "" {
|
|
responseID = strings.TrimSpace(values[2].String())
|
|
}
|
|
// 仅 terminal 事件兜底读取顶层 id,避免把 event_id 当成 response_id 关联到 turn。
|
|
if responseID == "" && isTerminalEvent(eventType) {
|
|
responseID = strings.TrimSpace(values[3].String())
|
|
}
|
|
now := nowFn()
|
|
|
|
if state.firstTokenMs == nil && isTokenEvent(eventType) {
|
|
ms := int(now.Sub(startAt).Milliseconds())
|
|
if ms >= 0 {
|
|
state.firstTokenMs = &ms
|
|
}
|
|
if state.activeTurn != nil && state.activeTurn.firstTokenMs == nil {
|
|
tms := int(now.Sub(state.activeTurn.startAt).Milliseconds())
|
|
if tms >= 0 {
|
|
state.activeTurn.firstTokenMs = &tms
|
|
}
|
|
}
|
|
}
|
|
parsedUsage := parseUsageAndAccumulate(state, message, eventType, onUsageParseFailure)
|
|
observed := observedUpstreamEvent{
|
|
eventType: eventType,
|
|
responseID: responseID,
|
|
usage: parsedUsage,
|
|
}
|
|
var turnTiming *relayTurnTiming
|
|
if responseID != "" {
|
|
turnTiming = openAIWSRelayGetOrInitTurnTiming(state, responseID, now)
|
|
if turnTiming != nil && turnTiming.firstTokenMs == nil && isTokenEvent(eventType) {
|
|
ms := int(now.Sub(turnTiming.startAt).Milliseconds())
|
|
if ms >= 0 {
|
|
turnTiming.firstTokenMs = &ms
|
|
}
|
|
}
|
|
} else {
|
|
turnTiming = state.activeTurn
|
|
}
|
|
observeRelayTurnResponseModel(turnTiming, firstRelayResponseModel(message), isTerminalEvent(eventType))
|
|
if !isTerminalEvent(eventType) {
|
|
return observed
|
|
}
|
|
observed.terminal = true
|
|
state.terminalEventType = eventType
|
|
if responseID != "" {
|
|
state.lastResponseID = responseID
|
|
if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok {
|
|
observed.responseModel = relayTurnResponseModel(&turnTiming)
|
|
observed.responseConflict = turnTiming.responseModelConflict
|
|
state.lastResponseModel = observed.responseModel
|
|
state.responseConflict = observed.responseConflict
|
|
duration := now.Sub(turnTiming.startAt)
|
|
if duration < 0 {
|
|
duration = 0
|
|
}
|
|
observed.startedAt = turnTiming.startAt
|
|
observed.duration = duration
|
|
observed.firstToken = openAIWSRelayCloneIntPtr(turnTiming.firstTokenMs)
|
|
}
|
|
}
|
|
return observed
|
|
}
|
|
|
|
func emitTurnComplete(
|
|
onTurnComplete func(turn RelayTurnResult),
|
|
state *relayState,
|
|
observed observedUpstreamEvent,
|
|
) {
|
|
if onTurnComplete == nil || !observed.terminal {
|
|
return
|
|
}
|
|
responseID := strings.TrimSpace(observed.responseID)
|
|
if responseID == "" {
|
|
return
|
|
}
|
|
requestModel := ""
|
|
if state != nil {
|
|
requestModel = state.currentRequestModel()
|
|
}
|
|
onTurnComplete(RelayTurnResult{
|
|
RequestModel: requestModel,
|
|
ResponseModel: observed.responseModel,
|
|
ResponseModelConflict: observed.responseConflict,
|
|
Usage: observed.usage,
|
|
RequestID: responseID,
|
|
TerminalEventType: observed.eventType,
|
|
StartedAt: observed.startedAt,
|
|
Duration: observed.duration,
|
|
FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken),
|
|
})
|
|
}
|
|
|
|
func firstRelayResponseModel(message []byte) string {
|
|
if len(message) == 0 {
|
|
return ""
|
|
}
|
|
values := gjson.GetManyBytes(message, "response.model", "model")
|
|
for _, value := range values {
|
|
if value.Type != gjson.String {
|
|
continue
|
|
}
|
|
if model := strings.TrimSpace(value.String()); model != "" {
|
|
return model
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func observeRelayTurnResponseModel(turn *relayTurnTiming, model string, terminal bool) {
|
|
if turn == nil {
|
|
return
|
|
}
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return
|
|
}
|
|
current := relayTurnResponseModel(turn)
|
|
if current != "" && !strings.EqualFold(current, model) {
|
|
turn.responseModelConflict = true
|
|
}
|
|
if terminal {
|
|
turn.terminalResponseModel = model
|
|
return
|
|
}
|
|
if turn.firstResponseModel == "" {
|
|
turn.firstResponseModel = model
|
|
}
|
|
}
|
|
|
|
func relayTurnResponseModel(turn *relayTurnTiming) string {
|
|
if turn == nil {
|
|
return ""
|
|
}
|
|
if turn.terminalResponseModel != "" {
|
|
return turn.terminalResponseModel
|
|
}
|
|
return turn.firstResponseModel
|
|
}
|
|
|
|
func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
if state.turnTimingByID == nil {
|
|
state.turnTimingByID = make(map[string]*relayTurnTiming, 8)
|
|
}
|
|
timing, ok := state.turnTimingByID[responseID]
|
|
if !ok || timing == nil || timing.startAt.IsZero() {
|
|
startAt := state.consumePendingTurnStartedAt()
|
|
if startAt.IsZero() {
|
|
startAt = now
|
|
}
|
|
timing = &relayTurnTiming{startAt: startAt}
|
|
state.turnTimingByID[responseID] = timing
|
|
state.activeTurn = timing
|
|
return timing
|
|
}
|
|
return timing
|
|
}
|
|
|
|
func (s *relayState) setPendingTurnStartedAt(startedAt time.Time) {
|
|
if s == nil || startedAt.IsZero() {
|
|
return
|
|
}
|
|
startedAtCopy := startedAt
|
|
s.pendingTurnStart.Store(&startedAtCopy)
|
|
}
|
|
|
|
func (s *relayState) consumePendingTurnStartedAt() time.Time {
|
|
if s == nil {
|
|
return time.Time{}
|
|
}
|
|
startedAt := s.pendingTurnStart.Swap(nil)
|
|
if startedAt == nil {
|
|
return time.Time{}
|
|
}
|
|
return *startedAt
|
|
}
|
|
|
|
func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) {
|
|
if state == nil || state.turnTimingByID == nil {
|
|
return relayTurnTiming{}, false
|
|
}
|
|
timing, ok := state.turnTimingByID[responseID]
|
|
if !ok || timing == nil {
|
|
return relayTurnTiming{}, false
|
|
}
|
|
delete(state.turnTimingByID, responseID)
|
|
if state.activeTurn == timing {
|
|
state.activeTurn = nil
|
|
}
|
|
return *timing, true
|
|
}
|
|
|
|
func openAIWSRelayCloneIntPtr(v *int) *int {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
cloned := *v
|
|
return &cloned
|
|
}
|
|
|
|
func parseUsageAndAccumulate(
|
|
state *relayState,
|
|
message []byte,
|
|
eventType string,
|
|
onParseFailure func(eventType string, usageRaw string),
|
|
) Usage {
|
|
if state == nil || len(message) == 0 || !shouldParseUsage(eventType) {
|
|
return Usage{}
|
|
}
|
|
usageResult := gjson.GetBytes(message, "response.usage")
|
|
if !usageResult.Exists() {
|
|
return Usage{}
|
|
}
|
|
usageRaw := strings.TrimSpace(usageResult.Raw)
|
|
if usageRaw == "" || !strings.HasPrefix(usageRaw, "{") {
|
|
recordUsageParseFailure()
|
|
if onParseFailure != nil {
|
|
onParseFailure(eventType, usageRaw)
|
|
}
|
|
return Usage{}
|
|
}
|
|
|
|
inputResult := gjson.GetBytes(message, "response.usage.input_tokens")
|
|
if !inputResult.Exists() {
|
|
inputResult = gjson.GetBytes(message, "response.usage.prompt_tokens")
|
|
}
|
|
outputResult := gjson.GetBytes(message, "response.usage.output_tokens")
|
|
if !outputResult.Exists() {
|
|
outputResult = gjson.GetBytes(message, "response.usage.completion_tokens")
|
|
}
|
|
cachedResult := gjson.GetBytes(message, "response.usage.input_tokens_details.cached_tokens")
|
|
if !cachedResult.Exists() {
|
|
cachedResult = gjson.GetBytes(message, "response.usage.prompt_tokens_details.cached_tokens")
|
|
}
|
|
imageTokens := usageResult.Get("output_tokens_details.image_tokens").Int()
|
|
if imageTokens == 0 {
|
|
imageTokens = usageResult.Get("completion_tokens_details.image_tokens").Int()
|
|
}
|
|
|
|
inputTokens, inputOK := parseUsageIntField(inputResult, true)
|
|
outputTokens, outputOK := parseUsageIntField(outputResult, true)
|
|
cachedTokens, cachedOK := parseUsageIntField(cachedResult, false)
|
|
if !inputOK || !outputOK || !cachedOK {
|
|
recordUsageParseFailure()
|
|
if onParseFailure != nil {
|
|
onParseFailure(eventType, usageRaw)
|
|
}
|
|
// 解析失败时不做部分字段累加,避免计费 usage 出现“半有效”状态。
|
|
return Usage{}
|
|
}
|
|
parsedUsage := Usage{
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
CacheCreationInputTokens: openAICacheCreationTokensFromUsage(usageResult),
|
|
CacheReadInputTokens: cachedTokens,
|
|
ImageOutputTokens: int(imageTokens),
|
|
}
|
|
|
|
state.usage.InputTokens += parsedUsage.InputTokens
|
|
state.usage.OutputTokens += parsedUsage.OutputTokens
|
|
state.usage.CacheCreationInputTokens += parsedUsage.CacheCreationInputTokens
|
|
state.usage.CacheReadInputTokens += parsedUsage.CacheReadInputTokens
|
|
state.usage.ImageOutputTokens += parsedUsage.ImageOutputTokens
|
|
return parsedUsage
|
|
}
|
|
|
|
func parseUsageIntField(value gjson.Result, required bool) (int, bool) {
|
|
if !value.Exists() {
|
|
return 0, !required
|
|
}
|
|
if value.Type != gjson.Number {
|
|
return 0, false
|
|
}
|
|
return int(value.Int()), true
|
|
}
|
|
|
|
func openAICacheCreationTokensFromUsage(value gjson.Result) int {
|
|
for _, field := range []string{
|
|
"input_tokens_details.cache_write_tokens",
|
|
"prompt_tokens_details.cache_write_tokens",
|
|
"input_tokens_details.cache_creation_tokens",
|
|
"prompt_tokens_details.cache_creation_tokens",
|
|
} {
|
|
result := value.Get(field)
|
|
if result.Exists() {
|
|
return max(int(result.Int()), 0)
|
|
}
|
|
}
|
|
for _, field := range []string{
|
|
"cache_write_tokens",
|
|
"cache_creation_input_tokens",
|
|
"cache_write_input_tokens",
|
|
"cache_creation_tokens",
|
|
} {
|
|
if tokens := int(value.Get(field).Int()); tokens > 0 {
|
|
return tokens
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func enrichResult(result *RelayResult, state *relayState, duration time.Duration) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
result.Duration = duration
|
|
if state == nil {
|
|
return
|
|
}
|
|
result.RequestModel = state.currentRequestModel()
|
|
result.ResponseModel = state.lastResponseModel
|
|
result.ResponseModelConflict = state.responseConflict
|
|
result.Usage = state.usage
|
|
result.RequestID = state.lastResponseID
|
|
result.TerminalEventType = state.terminalEventType
|
|
result.FirstTokenMs = state.firstTokenMs
|
|
}
|
|
|
|
func (s *relayState) setRequestModel(model string) {
|
|
if s == nil || model == "" {
|
|
return
|
|
}
|
|
s.requestModelMu.Lock()
|
|
s.requestModel = model
|
|
s.requestModelMu.Unlock()
|
|
}
|
|
|
|
func (s *relayState) currentRequestModel() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
s.requestModelMu.RLock()
|
|
defer s.requestModelMu.RUnlock()
|
|
return s.requestModel
|
|
}
|
|
|
|
func isDisconnectError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) {
|
|
return true
|
|
}
|
|
switch coderws.CloseStatus(err) {
|
|
case coderws.StatusNormalClosure, coderws.StatusGoingAway, coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
|
|
return true
|
|
}
|
|
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
if message == "" {
|
|
return false
|
|
}
|
|
return strings.Contains(message, "failed to read frame header: eof") ||
|
|
strings.Contains(message, "unexpected eof") ||
|
|
strings.Contains(message, "use of closed network connection") ||
|
|
strings.Contains(message, "connection reset by peer") ||
|
|
strings.Contains(message, "broken pipe")
|
|
}
|
|
|
|
func isTerminalEvent(eventType string) bool {
|
|
switch eventType {
|
|
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func shouldParseUsage(eventType string) bool {
|
|
switch eventType {
|
|
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isTokenEvent(eventType string) bool {
|
|
eventType = strings.TrimSpace(eventType)
|
|
return strings.HasSuffix(eventType, ".delta") ||
|
|
eventType == "response.output_text.done" ||
|
|
eventType == "response.function_call_arguments.done"
|
|
}
|
|
|
|
func minDuration(a, b time.Duration) time.Duration {
|
|
if a <= 0 {
|
|
return b
|
|
}
|
|
if b <= 0 {
|
|
return a
|
|
}
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func waitRelayExit(exitCh <-chan relayExitSignal, timeout time.Duration) (relayExitSignal, bool) {
|
|
if timeout <= 0 {
|
|
timeout = 200 * time.Millisecond
|
|
}
|
|
select {
|
|
case sig := <-exitCh:
|
|
return sig, true
|
|
case <-time.After(timeout):
|
|
return relayExitSignal{}, false
|
|
}
|
|
}
|