iop/apps/edge/internal/openai/gemini_handler.go

572 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 geminiCallerModelLabel = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$`)
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) || !geminiCallerModelLabel.MatchString(callerModel) || strings.TrimSpace(callerModel) != 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 !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" {
if len(toolMessages) > 0 {
if len(texts) > 0 || len(reasoning) > 0 || len(toolCalls) > 0 {
return nil, fmt.Errorf("model functionResponse cannot be mixed with assistant content")
}
return toolMessages, nil
}
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
}