iop/apps/edge/internal/openai/gemini_bridge.go
toki 0e17a9d9ec fix(edge): Gemini 거부 출처를 구분한다
Edge 내부 오류가 provider HTTP 거부로 기록되지 않도록 실제 tunnel status에서만 관측하고, dev release가 stale tracking ref와 존재하지 않는 package root에 막히지 않게 한다.
2026-08-13 10:34:33 +09:00

336 lines
9.1 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
)
type geminiBridgeResponseWriter struct {
target http.ResponseWriter
header http.Header
status int
committed bool
providerStatusObserved bool
observeProviderStatus func(int)
buffer bytes.Buffer
stream *geminiBridgeStream
}
func newGeminiBridgeResponseWriter(target http.ResponseWriter, model string, observeProviderStatus func(int)) *geminiBridgeResponseWriter {
return &geminiBridgeResponseWriter{
target: target, header: make(http.Header), observeProviderStatus: observeProviderStatus, stream: newGeminiBridgeStream(target, model),
}
}
func (w *geminiBridgeResponseWriter) Header() http.Header { return w.header }
func (w *geminiBridgeResponseWriter) observeActualProviderHTTPStatus(status int) {
if status < http.StatusBadRequest || w.providerStatusObserved || w.observeProviderStatus == nil {
return
}
w.providerStatusObserved = true
w.observeProviderStatus(status)
}
func (w *geminiBridgeResponseWriter) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
}
func (w *geminiBridgeResponseWriter) Write(payload []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
if w.status >= http.StatusBadRequest {
return w.buffer.Write(payload)
}
w.commit()
if err := w.stream.Feed(payload); err != nil {
_ = w.stream.Error("upstream stream could not be translated")
return 0, err
}
return len(payload), nil
}
func (w *geminiBridgeResponseWriter) Flush() {
if w.status == 0 {
w.status = http.StatusOK
}
if w.status < http.StatusBadRequest {
w.commit()
}
if flusher, ok := w.target.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *geminiBridgeResponseWriter) commit() {
if w.committed {
return
}
w.target.Header().Set("Content-Type", "text/event-stream")
w.target.Header().Set("Cache-Control", "no-cache")
w.target.Header().Del("Content-Length")
w.target.WriteHeader(http.StatusOK)
w.committed = true
}
func (w *geminiBridgeResponseWriter) Finish() {
if w.status == 0 {
writeGeminiError(w.target, http.StatusBadGateway, "UNAVAILABLE", "runtime request failed")
return
}
if w.status >= http.StatusBadRequest {
status, code := geminiHTTPError(w.status)
writeGeminiError(w.target, status, code, geminiSafeErrorMessage(status))
return
}
w.commit()
if err := w.stream.Finish(); err != nil {
_ = w.stream.Error("upstream stream could not be translated")
}
if flusher, ok := w.target.(http.Flusher); ok {
flusher.Flush()
}
}
func geminiHTTPError(status int) (int, string) {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return http.StatusUnauthorized, "UNAUTHENTICATED"
case status >= 400 && status < 500:
return http.StatusBadRequest, "INVALID_ARGUMENT"
default:
return http.StatusBadGateway, "UNAVAILABLE"
}
}
func geminiSafeErrorMessage(status int) string {
if status == http.StatusUnauthorized {
return "authentication failed"
}
if status == http.StatusBadRequest {
return "request is invalid"
}
return "runtime request failed"
}
type geminiBridgeToolState struct {
id string
name string
thoughtSignature string
arguments strings.Builder
}
type geminiBridgeStream struct {
w http.ResponseWriter
model string
pendingSSE []byte
tools map[int]*geminiBridgeToolState
finish string
usage map[string]int
stopped bool
errored bool
}
func newGeminiBridgeStream(w http.ResponseWriter, model string) *geminiBridgeStream {
return &geminiBridgeStream{w: w, model: model, tools: make(map[int]*geminiBridgeToolState), usage: make(map[string]int)}
}
func (s *geminiBridgeStream) Feed(chunk []byte) error {
if s.stopped {
return nil
}
s.pendingSSE = append(s.pendingSSE, chunk...)
s.pendingSSE = bytes.ReplaceAll(s.pendingSSE, []byte("\r\n"), []byte("\n"))
for {
index := bytes.Index(s.pendingSSE, []byte("\n\n"))
if index < 0 {
return nil
}
event := append([]byte(nil), s.pendingSSE[:index]...)
s.pendingSSE = s.pendingSSE[index+2:]
if err := s.consumeSSEEvent(event); err != nil {
return err
}
if s.stopped {
s.pendingSSE = nil
return nil
}
}
}
func (s *geminiBridgeStream) consumeSSEEvent(event []byte) error {
var lines [][]byte
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
lines = append(lines, bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))))
}
}
if len(lines) == 0 {
return nil
}
payload := bytes.Join(lines, []byte("\n"))
if bytes.Equal(payload, []byte("[DONE]")) {
return s.Finish()
}
var chunk geminiChatStreamChunk
if err := json.Unmarshal(payload, &chunk); err != nil {
return fmt.Errorf("decode Chat SSE: %w", err)
}
if chunk.Error != nil {
return s.Error("upstream request failed")
}
if chunk.Usage != nil {
setGeminiUsage(s.usage, "promptTokenCount", chunk.Usage.PromptTokens)
setGeminiUsage(s.usage, "candidatesTokenCount", chunk.Usage.CompletionTokens)
setGeminiUsage(s.usage, "totalTokenCount", chunk.Usage.TotalTokens)
if chunk.Usage.PromptTokensDetails != nil {
setGeminiUsage(s.usage, "cachedContentTokenCount", chunk.Usage.PromptTokensDetails.CachedTokens)
}
if chunk.Usage.CompletionTokensDetails != nil {
setGeminiUsage(s.usage, "thoughtsTokenCount", chunk.Usage.CompletionTokensDetails.ReasoningTokens)
}
}
for _, choice := range chunk.Choices {
reasoning := choice.Delta.ReasoningContent
if reasoning == "" {
reasoning = choice.Delta.Reasoning
}
if reasoning != "" {
if err := s.emitParts([]any{map[string]any{"text": reasoning, "thought": true}}, ""); err != nil {
return err
}
}
if choice.Delta.Content != "" {
if err := s.emitParts([]any{map[string]any{"text": choice.Delta.Content}}, ""); err != nil {
return err
}
}
for _, delta := range choice.Delta.ToolCalls {
state := s.tools[delta.Index]
if state == nil {
state = &geminiBridgeToolState{}
s.tools[delta.Index] = state
}
if delta.Function.Name != "" {
state.name = delta.Function.Name
}
if delta.ID != "" {
if !geminiToolCallID.MatchString(delta.ID) || (state.id != "" && state.id != delta.ID) {
return fmt.Errorf("tool call id is invalid")
}
state.id = delta.ID
}
if delta.ExtraContent.Google != nil && delta.ExtraContent.Google.ThoughtSignature != "" {
state.thoughtSignature = delta.ExtraContent.Google.ThoughtSignature
}
if state.arguments.Len()+len(delta.Function.Arguments) > geminiToolArgumentLimit {
return fmt.Errorf("tool arguments exceed limit")
}
state.arguments.WriteString(delta.Function.Arguments)
}
if choice.FinishReason != nil {
s.finish = *choice.FinishReason
}
}
return nil
}
func setGeminiUsage(target map[string]int, key string, value *int) {
if value != nil {
target[key] = *value
}
}
func (s *geminiBridgeStream) emitParts(parts []any, finish string) error {
candidate := map[string]any{"content": map[string]any{"role": "model", "parts": parts}}
if finish != "" {
candidate["finishReason"] = finish
}
payload := map[string]any{"candidates": []any{candidate}, "modelVersion": s.model}
if len(s.usage) > 0 {
payload["usageMetadata"] = s.usage
}
return writeGeminiSSE(s.w, payload)
}
func (s *geminiBridgeStream) emitTools() error {
if len(s.tools) == 0 {
return nil
}
indices := make([]int, 0, len(s.tools))
for index := range s.tools {
indices = append(indices, index)
}
sort.Ints(indices)
parts := make([]any, 0, len(indices))
seenIDs := make(map[string]struct{})
for _, index := range indices {
state := s.tools[index]
if !geminiPathToken.MatchString(state.name) {
return fmt.Errorf("tool name is invalid")
}
var args map[string]any
if json.Unmarshal([]byte(state.arguments.String()), &args) != nil {
return fmt.Errorf("tool arguments are invalid")
}
call := map[string]any{"name": state.name, "args": args}
if state.id != "" {
if _, duplicate := seenIDs[state.id]; duplicate {
return fmt.Errorf("duplicate tool call id")
}
seenIDs[state.id] = struct{}{}
call["id"] = state.id
}
part := map[string]any{"functionCall": call}
if state.thoughtSignature != "" {
part["thoughtSignature"] = state.thoughtSignature
}
parts = append(parts, part)
}
return s.emitParts(parts, "")
}
func (s *geminiBridgeStream) Finish() error {
if s.stopped {
return nil
}
if len(bytes.TrimSpace(s.pendingSSE)) > 0 {
return fmt.Errorf("truncated Chat SSE")
}
if err := s.emitTools(); err != nil {
return err
}
finish := "STOP"
if s.finish == "length" {
finish = "MAX_TOKENS"
}
s.stopped = true
return s.emitParts([]any{}, finish)
}
func (s *geminiBridgeStream) Error(message string) error {
if s.errored || s.stopped {
return nil
}
s.errored, s.stopped = true, true
return writeGeminiSSE(s.w, geminiErrorResponse{Error: geminiErrorBody{
Code: http.StatusBadGateway, Message: message, Status: "UNAVAILABLE",
}})
}
func writeGeminiSSE(w http.ResponseWriter, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err
}