Edge 내부 오류가 provider HTTP 거부로 기록되지 않도록 실제 tunnel status에서만 관측하고, dev release가 stale tracking ref와 존재하지 않는 package root에 막히지 않게 한다.
336 lines
9.1 KiB
Go
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
|
|
}
|