Edge 내부 오류가 provider HTTP 거부로 기록되지 않도록 실제 tunnel status에서만 관측하고, dev release가 stale tracking ref와 존재하지 않는 package root에 막히지 않게 한다.
565 lines
17 KiB
Go
565 lines
17 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var geminiPathToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
var geminiToolCallID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
|
|
|
const geminiRejectionLogMessage = "edge_gemini_rejection"
|
|
|
|
type geminiRejectionClass string
|
|
|
|
const (
|
|
geminiRejectionPreIngress geminiRejectionClass = "pre_ingress"
|
|
geminiRejectionProviderHTTP geminiRejectionClass = "provider_http"
|
|
)
|
|
|
|
func isGeminiRequest(r *http.Request) bool {
|
|
return r != nil && strings.HasPrefix(r.URL.Path, geminiPathPrefix)
|
|
}
|
|
|
|
func writeGeminiError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, geminiErrorResponse{Error: geminiErrorBody{
|
|
Code: status, Message: message, Status: code,
|
|
}})
|
|
}
|
|
|
|
func (s *Server) observeGeminiRejection(class geminiRejectionClass, status int) {
|
|
s.logger.Info(
|
|
geminiRejectionLogMessage,
|
|
zap.String("surface", "gemini"),
|
|
zap.String("bridge", "chat"),
|
|
zap.String("rejection_class", string(class)),
|
|
zap.Int("http_status", status),
|
|
)
|
|
}
|
|
|
|
func (s *Server) writeGeminiPreIngressError(w http.ResponseWriter, status int, code, message string) {
|
|
s.observeGeminiRejection(geminiRejectionPreIngress, status)
|
|
writeGeminiError(w, status, code, message)
|
|
}
|
|
|
|
func (s *Server) handleGeminiStreamGenerateContent(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.writeGeminiPreIngressError(w, http.StatusMethodNotAllowed, "INVALID_ARGUMENT", "method not allowed")
|
|
return
|
|
}
|
|
routeID, callerModel, err := parseGeminiStreamPath(r)
|
|
if err != nil {
|
|
s.writeGeminiPreIngressError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "request path is invalid")
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
body, err := readOpenAIIngressBody(w, r, s.maxIngressSnapshotBytes())
|
|
if err != nil {
|
|
s.writeGeminiPreIngressError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "request body is invalid")
|
|
return
|
|
}
|
|
chatBody, err := prepareGeminiChatBridge(body, routeID)
|
|
if err != nil {
|
|
s.writeGeminiPreIngressError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "request body is invalid")
|
|
return
|
|
}
|
|
|
|
internal := r.Clone(r.Context())
|
|
internal.URL.Path = "/v1/chat/completions"
|
|
internal.URL.RawPath = ""
|
|
internal.URL.RawQuery = ""
|
|
internal.RequestURI = "/v1/chat/completions"
|
|
internal.Body = io.NopCloser(bytes.NewReader(chatBody))
|
|
internal.ContentLength = int64(len(chatBody))
|
|
internal.Header = r.Header.Clone()
|
|
internal.Header.Del("Authorization")
|
|
internal.Header.Del("X-Goog-Api-Key")
|
|
internal.Header.Set("Content-Type", "application/json")
|
|
|
|
bridge := newGeminiBridgeResponseWriter(w, callerModel, func(status int) {
|
|
s.observeGeminiRejection(geminiRejectionProviderHTTP, status)
|
|
})
|
|
s.handleChatCompletions(bridge, internal)
|
|
bridge.Finish()
|
|
}
|
|
|
|
func parseGeminiStreamPath(r *http.Request) (string, string, error) {
|
|
if r == nil || r.URL == nil {
|
|
return "", "", fmt.Errorf("missing URL")
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, geminiPathPrefix), "/")
|
|
if len(parts) != 4 || parts[1] != "v1beta" || parts[2] != "models" {
|
|
return "", "", fmt.Errorf("unexpected path")
|
|
}
|
|
const suffix = ":streamGenerateContent"
|
|
if !strings.HasSuffix(parts[3], suffix) {
|
|
return "", "", fmt.Errorf("unexpected method")
|
|
}
|
|
routeID := parts[0]
|
|
callerModel := strings.TrimSuffix(parts[3], suffix)
|
|
if !geminiPathToken.MatchString(routeID) || !geminiPathToken.MatchString(callerModel) {
|
|
return "", "", fmt.Errorf("invalid path token")
|
|
}
|
|
query := r.URL.Query()
|
|
if len(query) != 1 || len(query["alt"]) != 1 || query.Get("alt") != "sse" {
|
|
return "", "", fmt.Errorf("alt=sse is required")
|
|
}
|
|
return routeID, callerModel, nil
|
|
}
|
|
|
|
func prepareGeminiChatBridge(body []byte, routeID string) ([]byte, error) {
|
|
if err := validateJSONMembers(body); err != nil {
|
|
return nil, err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.DisallowUnknownFields()
|
|
var req geminiRequest
|
|
if err := decoder.Decode(&req); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireGeminiJSONEOF(decoder); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(req.Contents) == 0 {
|
|
return nil, fmt.Errorf("contents are required")
|
|
}
|
|
chat := map[string]any{
|
|
"model": routeID, "stream": true,
|
|
"stream_options": map[string]any{"include_usage": true},
|
|
}
|
|
messages := make([]map[string]any, 0, len(req.Contents)+1)
|
|
if req.SystemInstruction != nil {
|
|
text, err := geminiTextOnly(*req.SystemInstruction)
|
|
if err != nil || strings.TrimSpace(text) == "" {
|
|
return nil, fmt.Errorf("systemInstruction is invalid")
|
|
}
|
|
messages = append(messages, map[string]any{"role": "system", "content": text})
|
|
}
|
|
pendingCalls := newGeminiPendingCalls()
|
|
for contentIndex, content := range req.Contents {
|
|
converted, err := geminiContentToChat(content, contentIndex, pendingCalls)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, converted...)
|
|
}
|
|
chat["messages"] = messages
|
|
if config := req.GenerationConfig; config != nil {
|
|
if config.CandidateCount != nil && *config.CandidateCount != 1 {
|
|
return nil, fmt.Errorf("candidateCount must be one")
|
|
}
|
|
if config.MaxOutputTokens != nil {
|
|
if *config.MaxOutputTokens <= 0 {
|
|
return nil, fmt.Errorf("maxOutputTokens must be positive")
|
|
}
|
|
chat["max_tokens"] = *config.MaxOutputTokens
|
|
}
|
|
if len(config.StopSequences) > 0 {
|
|
chat["stop"] = config.StopSequences
|
|
}
|
|
if config.Temperature != nil {
|
|
if *config.Temperature < 0 || *config.Temperature > 2 {
|
|
return nil, fmt.Errorf("temperature is invalid")
|
|
}
|
|
}
|
|
if config.TopK != nil {
|
|
if *config.TopK <= 0 {
|
|
return nil, fmt.Errorf("topK is invalid")
|
|
}
|
|
}
|
|
if config.TopP != nil {
|
|
if *config.TopP < 0 || *config.TopP > 1 {
|
|
return nil, fmt.Errorf("topP is invalid")
|
|
}
|
|
}
|
|
if thinking := config.ThinkingConfig; thinking != nil {
|
|
googleThinking := make(map[string]any)
|
|
if thinking.IncludeThoughts != nil {
|
|
googleThinking["include_thoughts"] = *thinking.IncludeThoughts
|
|
}
|
|
if thinking.ThinkingBudget != nil {
|
|
if *thinking.ThinkingBudget < -1 {
|
|
return nil, fmt.Errorf("thinkingBudget is invalid")
|
|
}
|
|
googleThinking["thinking_budget"] = *thinking.ThinkingBudget
|
|
}
|
|
if len(googleThinking) > 0 {
|
|
// Gemini's OpenAI-compatible endpoint accepts native options only
|
|
// below extra_body.google; generic think/include_reasoning fields
|
|
// are rejected by that endpoint.
|
|
chat["extra_body"] = map[string]any{
|
|
"google": map[string]any{"thinking_config": googleThinking},
|
|
}
|
|
}
|
|
}
|
|
responseSchema, err := geminiExclusiveSchema(config.ResponseSchema, config.ResponseJSONSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("response schema is invalid")
|
|
}
|
|
switch config.ResponseMIMEType {
|
|
case "":
|
|
if responseSchema != nil {
|
|
return nil, fmt.Errorf("response MIME type is required")
|
|
}
|
|
case "text/plain":
|
|
if responseSchema != nil {
|
|
return nil, fmt.Errorf("text response schema is invalid")
|
|
}
|
|
case "application/json":
|
|
if responseSchema == nil {
|
|
chat["response_format"] = map[string]any{"type": "json_object"}
|
|
} else {
|
|
chat["response_format"] = map[string]any{
|
|
"type": "json_schema",
|
|
"json_schema": map[string]any{
|
|
"name": "agy_response", "strict": true, "schema": responseSchema,
|
|
},
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("response MIME type is invalid")
|
|
}
|
|
}
|
|
if len(req.Tools) > 0 {
|
|
tools := make([]map[string]any, 0)
|
|
for _, group := range req.Tools {
|
|
if len(group.FunctionDeclarations) == 0 {
|
|
return nil, fmt.Errorf("functionDeclarations are required")
|
|
}
|
|
for _, declaration := range group.FunctionDeclarations {
|
|
if !geminiPathToken.MatchString(declaration.Name) {
|
|
return nil, fmt.Errorf("function declaration is invalid")
|
|
}
|
|
schema, err := geminiExclusiveSchema(
|
|
declaration.Parameters, declaration.ParametersJSONSchema,
|
|
)
|
|
if err != nil || schema == nil {
|
|
return nil, fmt.Errorf("function schema is invalid")
|
|
}
|
|
if _, err := geminiExclusiveSchema(
|
|
declaration.Response, declaration.ResponseJSONSchema,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("function response schema is invalid")
|
|
}
|
|
function := map[string]any{"name": declaration.Name, "parameters": schema}
|
|
if declaration.Description != "" {
|
|
function["description"] = declaration.Description
|
|
}
|
|
tools = append(tools, map[string]any{"type": "function", "function": function})
|
|
}
|
|
}
|
|
chat["tools"] = tools
|
|
}
|
|
if req.ToolConfig != nil && req.ToolConfig.FunctionCallingConfig != nil {
|
|
switch strings.ToUpper(req.ToolConfig.FunctionCallingConfig.Mode) {
|
|
case "", "AUTO":
|
|
chat["tool_choice"] = "auto"
|
|
case "ANY":
|
|
chat["tool_choice"] = "required"
|
|
case "NONE":
|
|
chat["tool_choice"] = "none"
|
|
default:
|
|
return nil, fmt.Errorf("function calling mode is invalid")
|
|
}
|
|
}
|
|
return json.Marshal(chat)
|
|
}
|
|
|
|
func geminiExclusiveSchema(first, second json.RawMessage) (map[string]any, error) {
|
|
if len(first) > 0 && len(second) > 0 {
|
|
return nil, fmt.Errorf("schema alternatives conflict")
|
|
}
|
|
raw := first
|
|
if len(raw) == 0 {
|
|
raw = second
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(raw, &schema); err != nil || schema == nil {
|
|
return nil, fmt.Errorf("schema is not an object")
|
|
}
|
|
if err := normalizeGeminiSchemaTypes(schema); err != nil {
|
|
return nil, err
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
func normalizeGeminiSchemaTypes(value any) error {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, item := range typed {
|
|
if key == "type" {
|
|
name, ok := item.(string)
|
|
if !ok {
|
|
return fmt.Errorf("schema type is invalid")
|
|
}
|
|
normalized := strings.ToLower(name)
|
|
switch normalized {
|
|
case "null", "boolean", "object", "array", "number", "integer", "string":
|
|
typed[key] = normalized
|
|
default:
|
|
return fmt.Errorf("schema type is invalid")
|
|
}
|
|
continue
|
|
}
|
|
if err := normalizeGeminiSchemaTypes(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range typed {
|
|
if err := normalizeGeminiSchemaTypes(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func geminiTextOnly(content geminiContent) (string, error) {
|
|
// Gemini represents systemInstruction as Content and official agy 1.1.12
|
|
// labels that Content with the API-native "user" role.
|
|
if content.Role != "" && content.Role != "user" {
|
|
return "", fmt.Errorf("role is invalid")
|
|
}
|
|
texts := make([]string, 0, len(content.Parts))
|
|
for _, part := range content.Parts {
|
|
if part.Text == nil || part.FunctionCall != nil || part.FunctionResponse != nil || part.Thought || part.ThoughtSignature != "" {
|
|
return "", fmt.Errorf("only text is supported")
|
|
}
|
|
texts = append(texts, *part.Text)
|
|
}
|
|
return strings.Join(texts, "\n"), nil
|
|
}
|
|
|
|
type geminiPendingCall struct {
|
|
id string
|
|
name string
|
|
explicit bool
|
|
}
|
|
|
|
type geminiPendingCalls struct {
|
|
byID map[string]geminiPendingCall
|
|
byName map[string][]string
|
|
}
|
|
|
|
func newGeminiPendingCalls() *geminiPendingCalls {
|
|
return &geminiPendingCalls{byID: make(map[string]geminiPendingCall), byName: make(map[string][]string)}
|
|
}
|
|
|
|
func (p *geminiPendingCalls) add(name, id string, explicit bool) error {
|
|
if _, duplicate := p.byID[id]; duplicate {
|
|
return fmt.Errorf("duplicate functionCall id")
|
|
}
|
|
p.byID[id] = geminiPendingCall{id: id, name: name, explicit: explicit}
|
|
p.byName[name] = append(p.byName[name], id)
|
|
return nil
|
|
}
|
|
|
|
func (p *geminiPendingCalls) consume(name, id string) (string, error) {
|
|
if id != "" {
|
|
if !geminiToolCallID.MatchString(id) {
|
|
return "", fmt.Errorf("functionResponse id is invalid")
|
|
}
|
|
call, ok := p.byID[id]
|
|
if !ok || call.name != name {
|
|
return "", fmt.Errorf("functionResponse has no matching call")
|
|
}
|
|
p.remove(call)
|
|
return id, nil
|
|
}
|
|
ids := p.byName[name]
|
|
if len(ids) == 0 {
|
|
return "", fmt.Errorf("functionResponse has no matching call")
|
|
}
|
|
call, ok := p.byID[ids[0]]
|
|
if !ok || call.explicit {
|
|
return "", fmt.Errorf("functionResponse id is required")
|
|
}
|
|
p.remove(call)
|
|
return call.id, nil
|
|
}
|
|
|
|
func (p *geminiPendingCalls) remove(call geminiPendingCall) {
|
|
delete(p.byID, call.id)
|
|
ids := p.byName[call.name]
|
|
for index, id := range ids {
|
|
if id != call.id {
|
|
continue
|
|
}
|
|
ids = append(ids[:index], ids[index+1:]...)
|
|
break
|
|
}
|
|
if len(ids) == 0 {
|
|
delete(p.byName, call.name)
|
|
} else {
|
|
p.byName[call.name] = ids
|
|
}
|
|
}
|
|
|
|
func geminiContentToChat(content geminiContent, contentIndex int, pending *geminiPendingCalls) ([]map[string]any, error) {
|
|
role := strings.ToLower(strings.TrimSpace(content.Role))
|
|
if role != "user" && role != "model" {
|
|
return nil, fmt.Errorf("content role is invalid")
|
|
}
|
|
if len(content.Parts) == 0 {
|
|
return nil, fmt.Errorf("content parts are required")
|
|
}
|
|
var texts []string
|
|
var reasoning []string
|
|
var toolCalls []any
|
|
var toolMessages []map[string]any
|
|
for partIndex, part := range content.Parts {
|
|
set := 0
|
|
if part.Text != nil {
|
|
set++
|
|
}
|
|
if part.FunctionCall != nil {
|
|
set++
|
|
}
|
|
if part.FunctionResponse != nil {
|
|
set++
|
|
}
|
|
if set != 1 {
|
|
return nil, fmt.Errorf("content part is invalid")
|
|
}
|
|
if part.Text != nil {
|
|
if role == "model" && part.Thought {
|
|
reasoning = append(reasoning, *part.Text)
|
|
} else if part.Thought {
|
|
return nil, fmt.Errorf("user thought is invalid")
|
|
} else {
|
|
texts = append(texts, *part.Text)
|
|
}
|
|
continue
|
|
}
|
|
if part.FunctionCall != nil {
|
|
if role != "model" || !geminiPathToken.MatchString(part.FunctionCall.Name) {
|
|
return nil, fmt.Errorf("functionCall is invalid")
|
|
}
|
|
var args map[string]any
|
|
if json.Unmarshal(part.FunctionCall.Args, &args) != nil {
|
|
return nil, fmt.Errorf("functionCall args are invalid")
|
|
}
|
|
callID := part.FunctionCall.ID
|
|
explicitID := callID != ""
|
|
if !explicitID {
|
|
callID = fmt.Sprintf("gemini_call_%d_%d", contentIndex, partIndex)
|
|
} else if !geminiToolCallID.MatchString(callID) {
|
|
return nil, fmt.Errorf("functionCall id is invalid")
|
|
}
|
|
if err := pending.add(part.FunctionCall.Name, callID, explicitID); err != nil {
|
|
return nil, err
|
|
}
|
|
call := map[string]any{"id": callID, "type": "function", "function": map[string]any{"name": part.FunctionCall.Name, "arguments": string(part.FunctionCall.Args)}}
|
|
if part.ThoughtSignature != "" {
|
|
call["extra_content"] = openAIChatThoughtSignature(part.ThoughtSignature)
|
|
}
|
|
toolCalls = append(toolCalls, call)
|
|
continue
|
|
}
|
|
response := part.FunctionResponse
|
|
if role != "user" || !geminiPathToken.MatchString(response.Name) {
|
|
return nil, fmt.Errorf("functionResponse is invalid")
|
|
}
|
|
if !json.Valid(response.Response) {
|
|
return nil, fmt.Errorf("functionResponse has no matching call")
|
|
}
|
|
callID, err := pending.consume(response.Name, response.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toolMessages = append(toolMessages, map[string]any{"role": "tool", "tool_call_id": callID, "content": string(response.Response)})
|
|
}
|
|
if role == "model" {
|
|
message := map[string]any{"role": "assistant", "content": strings.Join(texts, "\n")}
|
|
if len(reasoning) > 0 {
|
|
message["reasoning_content"] = strings.Join(reasoning, "")
|
|
}
|
|
if len(toolCalls) > 0 {
|
|
message["tool_calls"] = toolCalls
|
|
}
|
|
return []map[string]any{message}, nil
|
|
}
|
|
if len(texts) > 0 {
|
|
toolMessages = append(toolMessages, map[string]any{"role": "user", "content": strings.Join(texts, "\n")})
|
|
}
|
|
if len(toolMessages) == 0 {
|
|
return nil, fmt.Errorf("user content is empty")
|
|
}
|
|
return toolMessages, nil
|
|
}
|
|
|
|
func validateJSONMembers(raw []byte) error {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
if err := validateGeminiJSONValue(dec); err != nil {
|
|
return err
|
|
}
|
|
return requireGeminiJSONEOF(dec)
|
|
}
|
|
|
|
func validateGeminiJSONValue(dec *json.Decoder) error {
|
|
token, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
delim, ok := token.(json.Delim)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch delim {
|
|
case '{':
|
|
seen := map[string]struct{}{}
|
|
for dec.More() {
|
|
keyToken, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key, ok := keyToken.(string)
|
|
if !ok {
|
|
return fmt.Errorf("object key is invalid")
|
|
}
|
|
if _, duplicate := seen[key]; duplicate {
|
|
return fmt.Errorf("duplicate member")
|
|
}
|
|
seen[key] = struct{}{}
|
|
if err := validateGeminiJSONValue(dec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
closeToken, err := dec.Token()
|
|
if err != nil || closeToken != json.Delim('}') {
|
|
return fmt.Errorf("object is invalid")
|
|
}
|
|
case '[':
|
|
for dec.More() {
|
|
if err := validateGeminiJSONValue(dec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
closeToken, err := dec.Token()
|
|
if err != nil || closeToken != json.Delim(']') {
|
|
return fmt.Errorf("array is invalid")
|
|
}
|
|
default:
|
|
return fmt.Errorf("JSON delimiter is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireGeminiJSONEOF(dec *json.Decoder) error {
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != io.EOF {
|
|
return fmt.Errorf("trailing JSON value")
|
|
}
|
|
return nil
|
|
}
|