package openai import ( "encoding/json" "fmt" "nub/internal/llm" ) const systemSeparator = "--- system ---\n" func buildRequest(req llm.Request, caps llm.Caps) wireRequest { wr := wireRequest{ Model: req.Model, MaxTokens: req.MaxTokens, Temperature: req.Temp, Stream: true, } if caps.UsageInStream { wr.StreamOptions = &streamOptions{IncludeUsage: true} } var messages []wireMessage for _, m := range req.Messages { messages = append(messages, mapMessage(m)...) } // System-Rolle je nach Endpoint-Capability (5.1 Punkt 4): "system" oder // "developer" als eigene Message-Rolle, sonst als erste User-Message mit // Trennmarker (Endpoint ohne System-Rollen-Unterstützung). if len(req.System) > 0 { text := blocksToText(req.System) switch caps.SystemRole { case "system", "developer": messages = append([]wireMessage{{Role: caps.SystemRole, Content: textContent(text)}}, messages...) default: messages = prependSystemAsUser(messages, text) } } wr.Messages = messages // "tools" weglassen statt leer senden (5.1 Punkt 2). if len(req.Tools) > 0 { wr.Tools = make([]wireToolDef, len(req.Tools)) for i, t := range req.Tools { wr.Tools[i] = wireToolDef{ Type: "function", Function: wireFunctionDef{ Name: t.Name, Description: t.Description, Parameters: t.Schema, }, } } } return wr } func mapMessage(m llm.Message) []wireMessage { var out []wireMessage role := string(m.Role) var textParts []string var toolCalls []wireToolCall flushAssistant := func() { if len(textParts) == 0 && len(toolCalls) == 0 { return } wm := wireMessage{Role: role, ToolCalls: toolCalls} if len(textParts) > 0 { wm.Content = textContent(joinText(textParts)) } out = append(out, wm) textParts = nil toolCalls = nil } for _, b := range m.Content { switch b.Kind { case llm.KindText: textParts = append(textParts, b.Text) case llm.KindThinking: // Reasoning-Blöcke werden beim Zurücksenden nicht erneut mitgeschickt; // sie sind rein empfangsseitig relevant (5.1 Punkt 5). case llm.KindToolUse: toolCalls = append(toolCalls, wireToolCall{ ID: b.ID, Type: "function", Function: wireToolCallFunc{ Name: b.Name, Arguments: string(b.Input), }, }) case llm.KindToolResult: flushAssistant() out = append(out, wireMessage{ Role: "tool", ToolCallID: b.ToolUseID, Content: textContent(blocksToText(b.Result)), }) case llm.KindImage: // v1: Bild-Content wird nicht gemappt (SupportsImages-Endpoints folgen später). } } flushAssistant() return out } func blocksToText(blocks []llm.Block) string { var parts []string for _, b := range blocks { if b.Kind == llm.KindText || b.Kind == llm.KindThinking { parts = append(parts, b.Text) } } return joinText(parts) } func joinText(parts []string) string { out := "" for i, p := range parts { if i > 0 { out += "\n" } out += p } return out } func textContent(s string) json.RawMessage { b, _ := json.Marshal(s) return b } // prependSystemAsUser wird verwendet, wenn Caps.SystemRole == "none" gesetzt // ist (Endpoint ohne System-Rolle): System-Text als erste User-Message mit // Trennmarker. func prependSystemAsUser(messages []wireMessage, systemText string) []wireMessage { marker := fmt.Sprintf("%s%s", systemSeparator, systemText) return append([]wireMessage{{Role: "user", Content: textContent(marker)}}, messages...) }