iop/apps/edge/internal/openai/stream_gate_tunnel_codec.go
toki c380892159 fix(openai): 채팅 터널 실패 종료를 보장한다
재시도 소진 뒤 거부된 터미널을 재생하지 않고 SSE 오류로 종료해야 클라이언트가 중단된 스트림 대신 명시적인 실패를 관측할 수 있다.
2026-07-30 17:42:25 +09:00

582 lines
16 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"iop/packages/go/streamgate"
)
// openAITunnelCodecState keeps caller-facing provider frames outside semantic
// evidence while the Core holds parsed endpoint events. It is reset before each
// recovery attempt, which is safe because path switches are allowed only before
// any response bytes are committed.
type openAITunnelCodecState struct {
mu sync.Mutex
endpoint string
releases [][]byte
terminal []byte
termSet bool
errorResponse *openAITunnelErrorResponse
}
type openAITunnelErrorResponse struct {
status int
headers map[string]string
body []byte
}
func (s *openAITunnelCodecState) reset() {
if s == nil {
return
}
s.mu.Lock()
s.releases = nil
s.terminal = nil
s.termSet = false
s.errorResponse = nil
s.mu.Unlock()
}
func (s *openAITunnelCodecState) bindEndpoint(endpoint string) bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.endpoint == "" {
s.endpoint = endpoint
}
return s.endpoint == endpoint
}
func (s *openAITunnelCodecState) endpointIsChat() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.endpoint == openAIRebuildEndpointChat
}
func (s *openAITunnelCodecState) stageErrorResponseStart(status int, headers map[string]string) {
if s == nil {
return
}
s.mu.Lock()
s.errorResponse = &openAITunnelErrorResponse{
status: status,
headers: cloneStringMap(headers),
}
s.mu.Unlock()
}
func (s *openAITunnelCodecState) appendErrorResponseWire(payload []byte) {
if s == nil || len(payload) == 0 {
return
}
s.mu.Lock()
if s.errorResponse != nil {
s.errorResponse.body = append(s.errorResponse.body, payload...)
}
s.mu.Unlock()
}
func (s *openAITunnelCodecState) popErrorResponse() (openAITunnelErrorResponse, bool) {
if s == nil {
return openAITunnelErrorResponse{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.errorResponse == nil {
return openAITunnelErrorResponse{}, false
}
response := openAITunnelErrorResponse{
status: s.errorResponse.status,
headers: cloneStringMap(s.errorResponse.headers),
body: append([]byte(nil), s.errorResponse.body...),
}
s.errorResponse = nil
return response, true
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
func (s *openAITunnelCodecState) pushRelease(payload []byte) {
if s == nil {
return
}
s.mu.Lock()
s.releases = append(s.releases, append([]byte(nil), payload...))
s.mu.Unlock()
}
func (s *openAITunnelCodecState) popRelease() ([]byte, bool) {
if s == nil {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.releases) == 0 {
return nil, false
}
payload := s.releases[0]
s.releases = s.releases[1:]
return append([]byte(nil), payload...), true
}
func (s *openAITunnelCodecState) setTerminal(payload []byte) {
if s == nil {
return
}
s.mu.Lock()
s.terminal = append([]byte(nil), payload...)
s.termSet = true
s.mu.Unlock()
}
func (s *openAITunnelCodecState) popTerminal() ([]byte, bool) {
if s == nil {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.termSet {
return nil, false
}
payload := append([]byte(nil), s.terminal...)
s.terminal = nil
s.termSet = false
return payload, true
}
// openAITunnelEndpointCodec parses Chat Completions or Responses SSE frames
// into semantic Core events while retaining each original frame for lossless
// downstream release.
type openAITunnelEndpointCodec struct {
endpoint string
state *openAITunnelCodecState
pending []byte
stagedWire []byte
chatTools map[int]openAITunnelToolIdentity
responseTools map[string]openAITunnelToolIdentity
terminal bool
}
type openAITunnelToolIdentity struct {
id string
name string
}
func newOpenAITunnelEndpointCodec(endpoint string, state *openAITunnelCodecState) *openAITunnelEndpointCodec {
if state == nil || (endpoint != openAIRebuildEndpointChat && endpoint != openAIRebuildEndpointResponses) {
return nil
}
if !state.bindEndpoint(endpoint) {
return nil
}
return &openAITunnelEndpointCodec{
endpoint: endpoint,
state: state,
chatTools: make(map[int]openAITunnelToolIdentity),
responseTools: make(map[string]openAITunnelToolIdentity),
}
}
func (c *openAITunnelEndpointCodec) decode(body []byte, flush bool) ([]streamgate.NormalizedEvent, error) {
if c == nil || c.terminal {
return nil, nil
}
c.pending = append(c.pending, body...)
var out []streamgate.NormalizedEvent
for {
frame, rest, ok := takeOpenAISSEFrame(c.pending)
if !ok {
break
}
c.pending = rest
events, err := c.decodeFrame(frame)
if err != nil {
return nil, err
}
out = append(out, events...)
if c.terminal {
c.pending = nil
return out, nil
}
}
if flush && len(c.pending) > 0 {
frame := append([]byte(nil), c.pending...)
c.pending = nil
events, err := c.decodeFrame(frame)
if err != nil {
return nil, err
}
out = append(out, events...)
}
return out, nil
}
func takeOpenAISSEFrame(buf []byte) (frame, rest []byte, ok bool) {
lf := bytes.Index(buf, []byte("\n\n"))
crlf := bytes.Index(buf, []byte("\r\n\r\n"))
end := -1
sepLen := 0
if lf >= 0 {
end, sepLen = lf, 2
}
if crlf >= 0 && (end < 0 || crlf < end) {
end, sepLen = crlf, 4
}
if end < 0 {
return nil, buf, false
}
frame = append([]byte(nil), buf[:end+sepLen]...)
rest = append([]byte(nil), buf[end+sepLen:]...)
return frame, rest, true
}
func openAISSEData(frame []byte) string {
normalized := strings.ReplaceAll(string(frame), "\r\n", "\n")
var lines []string
for _, line := range strings.Split(normalized, "\n") {
if !strings.HasPrefix(line, "data:") {
continue
}
lines = append(lines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
return strings.Join(lines, "\n")
}
func (c *openAITunnelEndpointCodec) decodeFrame(frame []byte) ([]streamgate.NormalizedEvent, error) {
data := openAISSEData(frame)
if data == "" && json.Valid(bytes.TrimSpace(frame)) {
data = string(bytes.TrimSpace(frame))
}
if strings.TrimSpace(data) == "[DONE]" {
return c.finishTerminal(frame, false)
}
var events []streamgate.NormalizedEvent
var err error
if c.endpoint == openAIRebuildEndpointResponses {
events, err = c.decodeResponsesTunnelFrame(data)
} else {
events, err = c.decodeChatTunnelFrame(data)
}
if err == nil {
// Continue below.
} else {
return nil, err
}
releaseAttached := false
for _, event := range events {
switch event.Kind() {
case streamgate.EventKindTextDelta, streamgate.EventKindReasoningDelta, streamgate.EventKindToolCallFragment:
if releaseAttached == false {
payload := append(append([]byte(nil), c.stagedWire...), frame...)
c.stagedWire = nil
c.state.pushRelease(payload)
releaseAttached = true
} else {
c.state.pushRelease(nil)
}
case streamgate.EventKindProviderError:
if releaseAttached {
return c.finishTerminal(nil, true)
}
return c.finishTerminal(frame, true)
}
}
if releaseAttached == false {
// Provider opening/metadata and protocol-level finish frames are wire-only:
// retain them until the next semantic release or transport terminal rather
// than manufacturing text evidence from their JSON payload.
c.stagedWire = append(c.stagedWire, frame...)
}
return events, nil
}
// finishTransport turns the physical END boundary into the only terminal when
// no [DONE] marker already did so. A non-2xx response is a provider-error
// lifecycle event even if its body was opaque JSON and therefore wire-only.
func (c *openAITunnelEndpointCodec) finishTransport(body []byte, providerError bool) ([]streamgate.NormalizedEvent, error) {
if c == nil || c.terminal {
return nil, nil
}
events, err := c.decode(body, true)
if err == nil && c.terminal == false {
terminal, terminalErr := c.finishTerminal(nil, providerError)
if terminalErr != nil {
return nil, terminalErr
}
return append(events, terminal...), nil
}
return events, err
}
func (c *openAITunnelEndpointCodec) finishTerminal(frame []byte, providerError bool) ([]streamgate.NormalizedEvent, error) {
if c.terminal {
return nil, nil
}
payload := append(append([]byte(nil), c.stagedWire...), frame...)
c.stagedWire = nil
c.state.setTerminal(payload)
c.terminal = true
if providerError {
ev, err := newOpenAIProviderErrorEvent(streamGateErrorTunnelFailed)
return []streamgate.NormalizedEvent{ev}, err
}
ev, err := streamgate.NewTerminalEvent(streamGateChannelDefault, time.Now())
return []streamgate.NormalizedEvent{ev}, err
}
func (c *openAITunnelEndpointCodec) decodeChatTunnelFrame(data string) ([]streamgate.NormalizedEvent, error) {
if strings.TrimSpace(data) == "" {
return nil, nil
}
var payload struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
Reasoning string `json:"reasoning"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &payload); err != nil {
return nil, nil
}
var events []streamgate.NormalizedEvent
for _, choice := range payload.Choices {
content := choice.Delta.Content
if content == "" {
content = choice.Message.Content
}
if content != "" {
ev, err := streamgate.NewTextDeltaEvent(streamGateChannelDefault, content, time.Now())
if err != nil {
return nil, err
}
events = append(events, ev)
}
reasoning := choice.Delta.ReasoningContent
if reasoning == "" {
reasoning = choice.Delta.Reasoning
}
if reasoning == "" {
reasoning = choice.Message.ReasoningContent
}
if reasoning != "" {
ev, err := streamgate.NewReasoningDeltaEvent(streamGateChannelDefault, reasoning, time.Now())
if err != nil {
return nil, err
}
events = append(events, ev)
}
for _, tool := range choice.Delta.ToolCalls {
identity := c.chatTools[tool.Index]
if tool.ID != "" {
identity.id = tool.ID
}
if tool.Function.Name != "" {
identity.name = tool.Function.Name
}
c.chatTools[tool.Index] = identity
if tool.Function.Arguments == "" {
continue
}
id := identity.id
if id == "" {
id = fmt.Sprintf("tool-%d", tool.Index)
}
name := identity.name
if name == "" {
name = "function"
}
ev, err := streamgate.NewToolCallFragmentEvent(streamGateChannelDefault, id, name, tool.Function.Arguments, time.Now())
if err != nil {
return nil, err
}
events = append(events, ev)
}
// finish_reason is endpoint protocol state, not the transport terminal.
// Its frame remains in the release queue until [DONE] or END closes once.
}
return events, nil
}
func (c *openAITunnelEndpointCodec) rememberResponseTool(identity openAITunnelToolIdentity, keys ...string) {
if identity.id == "" && identity.name == "" {
return
}
for _, key := range keys {
if key == "" {
continue
}
current := c.responseTools[key]
if identity.id != "" {
current.id = identity.id
}
if identity.name != "" {
current.name = identity.name
}
c.responseTools[key] = current
}
}
func (c *openAITunnelEndpointCodec) responseTool(keys ...string) openAITunnelToolIdentity {
var identity openAITunnelToolIdentity
for _, key := range keys {
candidate := c.responseTools[key]
if identity.id == "" {
identity.id = candidate.id
}
if identity.name == "" {
identity.name = candidate.name
}
}
return identity
}
func (c *openAITunnelEndpointCodec) decodeResponsesTunnelFrame(data string) ([]streamgate.NormalizedEvent, error) {
if strings.TrimSpace(data) == "" {
return nil, nil
}
var payload struct {
Type string `json:"type"`
Delta string `json:"delta"`
ItemID string `json:"item_id"`
CallID string `json:"call_id"`
Name string `json:"name"`
OutputIdx int `json:"output_index"`
OutputText string `json:"output_text"`
Item struct {
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
} `json:"item"`
Output []struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
}
if err := json.Unmarshal([]byte(data), &payload); err != nil {
return nil, nil
}
outputKey := fmt.Sprintf("output-%d", payload.OutputIdx)
c.rememberResponseTool(openAITunnelToolIdentity{id: payload.CallID, name: payload.Name}, payload.CallID, payload.ItemID, outputKey)
c.rememberResponseTool(openAITunnelToolIdentity{id: payload.Item.CallID, name: payload.Item.Name}, payload.Item.CallID, payload.Item.ID, outputKey)
if payload.Type == "" && (payload.OutputText != "" || len(payload.Output) > 0) {
var events []streamgate.NormalizedEvent
text := payload.OutputText
if text == "" {
for _, item := range payload.Output {
for _, content := range item.Content {
if content.Type == "output_text" {
text += content.Text
}
}
}
}
if text != "" {
event, eventErr := streamgate.NewTextDeltaEvent(streamGateChannelDefault, text, time.Now())
if eventErr != nil {
return nil, eventErr
}
events = append(events, event)
}
for i, item := range payload.Output {
key := fmt.Sprintf("output-%d", i)
c.rememberResponseTool(openAITunnelToolIdentity{id: item.CallID, name: item.Name}, item.CallID, item.ID, key)
if item.Type != "function_call" || item.Arguments == "" {
continue
}
identity := c.responseTool(item.CallID, item.ID, key)
id := identity.id
if id == "" {
id = fmt.Sprintf("call-%d", i)
}
name := identity.name
if name == "" {
name = "function"
}
event, eventErr := streamgate.NewToolCallFragmentEvent(streamGateChannelDefault, id, name, item.Arguments, time.Now())
if eventErr != nil {
return nil, eventErr
}
events = append(events, event)
}
return events, nil
}
switch payload.Type {
case "response.output_text.delta":
if payload.Delta == "" {
return nil, nil
}
ev, err := streamgate.NewTextDeltaEvent(streamGateChannelDefault, payload.Delta, time.Now())
return []streamgate.NormalizedEvent{ev}, err
case "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
if payload.Delta == "" {
return nil, nil
}
ev, err := streamgate.NewReasoningDeltaEvent(streamGateChannelDefault, payload.Delta, time.Now())
return []streamgate.NormalizedEvent{ev}, err
case "response.function_call_arguments.delta":
if payload.Delta == "" {
return nil, nil
}
identity := c.responseTool(payload.CallID, payload.ItemID, outputKey)
id := identity.id
if id == "" {
id = fmt.Sprintf("call-%d", payload.OutputIdx)
}
name := identity.name
if name == "" {
name = "function"
}
ev, err := streamgate.NewToolCallFragmentEvent(streamGateChannelDefault, id, name, payload.Delta, time.Now())
return []streamgate.NormalizedEvent{ev}, err
case "response.completed", "response.incomplete":
return nil, nil
case "response.failed", "error":
ev, err := newOpenAIProviderErrorEvent(streamGateErrorTunnelFailed)
return []streamgate.NormalizedEvent{ev}, err
default:
return nil, nil
}
}