package providers import ( "encoding/json" "fmt" "log/slog" "strings" ) func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream bool) map[string]any { // Gemini 2.5+: collapse tool_call cycles missing thought_signature. // Gemini requires thought_signature echoed back on every tool_call; models that // don't return it (e.g. gemini-3-flash) will cause HTTP 400 if sent as-is. // Tool results are folded into plain user messages to preserve context. inputMessages := req.Messages // Compute provider capability once: does this endpoint support Google's thought_signature? // We check providerType, name, apiBase, and the model string (robust detection for proxies/OpenRouter). supportsThoughtSignature := strings.Contains(strings.ToLower(p.providerType), "gemini") || strings.Contains(strings.ToLower(p.name), "gemini") || strings.Contains(strings.ToLower(p.apiBase), "generativelanguage") || strings.Contains(strings.ToLower(model), "gemini") || strings.ToLower(p.providerType) == "vertex" || strings.Contains(strings.ToLower(p.apiBase), "aiplatform") if supportsThoughtSignature { inputMessages = collapseToolCallsWithoutSig(inputMessages) } // Build raw-ID → tool-name index for role="tool" serialization. // Google Gemini's OpenAI-compat shim maps role=tool messages to native // FunctionResponse{name, response}; an empty name trips HTTP 400 ("Name // cannot be empty"). Lookup uses the raw ToolCallID to match history before // any wire-truncation. Trace: 019d8f33-2de1-7ab2-9a32-9df92cd610dd. toolNameByID := buildToolNameIndex(inputMessages) // Detect native OpenAI endpoint to enable developer role. // GPT-4o+ models prioritize "developer" messages over "system" for instruction // adherence. Non-OpenAI backends (proxies, Qwen, DeepSeek, etc.) reject "developer". // Matching OpenClaw TS: model-compat.ts → isOpenAINativeEndpoint(). useDevRole := isOpenAINativeEndpoint(p.apiBase) // A conversation where some assistant turn captured reasoning is running in // thinking mode, so every replayed assistant message must carry the field — // turns that produced no reasoning of their own included. DeepSeek rejects // the gap with HTTP 400 "The `reasoning_content` in the thinking mode must // be passed back to the API." Trace: 01a04c34-a74b-7be7-973e-2cb66ea68f3d. historyHasReasoning := false for _, m := range inputMessages { if m.Role == "assistant" && m.Thinking != "" { historyHasReasoning = true break } } // Convert messages to proper OpenAI wire format. // This is necessary because our internal Message/ToolCall structs don't match // the OpenAI API format (tool_calls need type+function wrapper, arguments as JSON string). // Also omits empty content on assistant messages with tool_calls (Gemini compatibility). msgs := make([]map[string]any, 0, len(inputMessages)) for _, m := range inputMessages { role := m.Role // Map "system" → "developer" for native OpenAI endpoints (GPT-4o+). // The developer role has higher instruction priority than system role. if useDevRole && role == "system" { role = "developer" } msg := map[string]any{ "role": role, } // Echo reasoning_content only for APIs/models that accept it on assistant history. // Together Qwen and many OpenAI-compat gateways reject unknown message fields → HTTP 400. // // Kimi Coding is stricter: when its server-side thinking is on (always-on for // kimi-k2-turbo-preview), assistant tool-call messages MUST carry // reasoning_content even if empty — otherwise upstream returns 400 "thinking // is enabled but reasoning_content is missing in assistant tool call message". // DeepSeek enforces the same rule, but only once the conversation is in // thinking mode (historyHasReasoning). if m.Role == "assistant" && openAIWireAssistantReasoningContent(model) { switch { case m.Thinking != "": msg["reasoning_content"] = m.Thinking case p.providerType == "kimi_coding" || historyHasReasoning: // Send empty string rather than omit the field — satisfies the // "must be present" check without inventing reasoning content. msg["reasoning_content"] = "" } } // Include content; omit empty content for assistant messages with tool_calls // (Gemini rejects empty content → "must include at least one parts field"). if m.Role == "user" && (len(m.Images) > 0 || len(m.Videos) > 0) { var parts []map[string]any // Text before images/videos — Together / Qwen vision examples use this order; OpenAI accepts both. if m.Content != "" { parts = append(parts, map[string]any{ "type": "text", "text": m.Content, }) } for _, img := range m.Images { urlVal := img.URL if urlVal == "" { urlVal = fmt.Sprintf("data:%s;base64,%s", img.MimeType, img.Data) } parts = append(parts, map[string]any{ "type": "image_url", "image_url": map[string]any{ "url": urlVal, }, }) } for _, vid := range m.Videos { urlVal := vid.URL if urlVal == "" { urlVal = fmt.Sprintf("data:%s;base64,%s", vid.MimeType, vid.Data) } parts = append(parts, map[string]any{ "type": "video_url", "video_url": map[string]any{ "url": urlVal, }, }) } msg["content"] = parts } else if m.Content != "" || len(m.ToolCalls) == 0 { msg["content"] = m.Content } // Convert tool_calls to OpenAI wire format: // {id, type: "function", function: {name, arguments: ""}} if len(m.ToolCalls) > 0 { toolCalls := make([]map[string]any, len(m.ToolCalls)) for i, tc := range m.ToolCalls { argsJSON, _ := json.Marshal(tc.Arguments) fn := map[string]any{ "name": tc.Name, "arguments": string(argsJSON), } if sig := tc.Metadata["thought_signature"]; sig != "" { // Only send thought_signature to providers that support it (Google/Gemini). // Non-Google providers will reject the unknown field with 422 Unprocessable Entity. if supportsThoughtSignature { fn["thought_signature"] = sig } } toolCalls[i] = map[string]any{ "id": p.wireToolCallID(tc.ID), "type": "function", "function": fn, } } msg["tool_calls"] = toolCalls } if m.ToolCallID != "" { msg["tool_call_id"] = p.wireToolCallID(m.ToolCallID) // `name` on role=tool is required by Google Gemini's OpenAI-compat shim // (FunctionResponse.name). Most other OpenAI-compat hosts (Together, Groq, // vLLM) either ignore or reject unknown fields — gate to Gemini only to // avoid silent 400s on stricter proxies. if supportsThoughtSignature && m.Role == "tool" { // Prefer the name carried on the message: it survives pruning, // truncation and tool_call collapse. Fall back to the reverse index // for history persisted before Message.ToolName existed. name := m.ToolName if name == "" { name = toolNameByID[m.ToolCallID] } if name == "" { // Gemini pairs functionCall↔functionResponse by name and has no // tool_call_id to fall back on, so an empty name is a hard 400 // ("Name cannot be empty"). A synthetic name is not an option // either — it would match no prior functionCall. Dropping the // unlabelled result is the only way to keep the request valid. // Reachable only for legacy history whose assistant tool_call is // already out of the window, so nothing dangles by removing it. slog.Warn("openai: dropping tool result with unresolvable tool name", "provider", p.name, "tool_call_id", m.ToolCallID) continue } msg["name"] = name } } msgs = append(msgs, msg) } // Apply DashScope cache_control wrapping (verified live 2026-05-08). // Uses 3-source detection from p.isDashScope() (URL + providerType + name) // to handle reverse-proxied endpoints. No-op for non-DashScope endpoints // or when env disabled. For native OpenAI, role mapping above renames // "system"→"developer" so wrap is a no-op (role guard). if p.isDashScope() && !dashScopeCacheDisabled() && len(msgs) > 0 { msgs[0] = wrapSystemForDashScopeCache(msgs[0]) } // Safety net: strip trailing assistant message to prevent HTTP 400 from // proxy providers (LiteLLM, OpenRouter) that don't support assistant prefill. // This should rarely trigger — the agent loop ensures user message is last. if len(msgs) > 0 { if role, _ := msgs[len(msgs)-1]["role"].(string); role == "assistant" { slog.Warn("openai: stripped trailing assistant message (unsupported prefill)", "provider", p.name, "model", model) msgs = msgs[:len(msgs)-1] } } body := map[string]any{ "model": model, "messages": msgs, "stream": stream, } if len(req.Tools) > 0 { body["tools"] = buildToolsPayload(p.schemaProviderName(), req.Tools) if tc, ok := req.Options[OptToolChoice]; ok && tc != nil { body["tool_choice"] = tc } else { body["tool_choice"] = "auto" } } // DashScope tool prefix cache: cache_control on last tool definition // caches the entire tools array (descriptions + schemas, ~5-10K tokens). // Combined with system block cache: 2/4 markers used, 99.5% hit rate verified. if p.isDashScope() && !dashScopeCacheDisabled() { if t, ok := body["tools"].([]map[string]any); ok && len(t) > 0 { markersFromSystem := 0 if len(msgs) > 0 { markersFromSystem = countCacheControlMarkers(msgs[0]) } body["tools"] = applyDashScopeToolPrefixCache(t, markersFromSystem) } } // Together returns HTTP 400 on some requests when stream_options is present. if stream && !p.isTogetherEndpoint() { body["stream_options"] = map[string]any{ "include_usage": true, } } // Merge options capabilityModel := modelFamily(model) if v, ok := req.Options[OptMaxTokens]; ok { // Fireworks requires stream=true for max_tokens > 4096. // Clamp proactively to avoid a 400 round-trip (their error format // doesn't match the generic clampMaxTokensFromError regex). if !stream && p.isFireworksEndpoint() { if maxTokens, isInt := v.(int); isInt && maxTokens > 4096 { v = 4096 slog.Debug("max_tokens clamped to 4096 for Fireworks non-streaming request", "provider", p.name, "model", model) } } if strings.HasPrefix(capabilityModel, "gpt-5") || strings.HasPrefix(capabilityModel, "o1") || strings.HasPrefix(capabilityModel, "o3") || strings.HasPrefix(capabilityModel, "o4") { body["max_completion_tokens"] = v } else { body["max_tokens"] = v } } if v, ok := req.Options[OptTemperature]; ok { // Certain model families don't support custom temperature (locked to default). // This is a model-level constraint, not provider-specific — applies to both OpenAI and Azure. // Note: gpt-5.X flagship models (gpt-5.1, gpt-5.4, gpt-5.5) DO support temperature; // the locked ones are the base gpt-5, gpt-5-chat, and the mini/nano reasoning variants. skipTemp := capabilityModel == "gpt-5" || strings.HasPrefix(capabilityModel, "gpt-5-chat") || strings.HasPrefix(capabilityModel, "gpt-5-mini") || strings.HasPrefix(capabilityModel, "gpt-5-nano") || strings.HasPrefix(capabilityModel, "o1") || strings.HasPrefix(capabilityModel, "o3") || strings.HasPrefix(capabilityModel, "o4") // Kimi Coding rejects any temperature override — `invalid temperature: only // 1 is allowed for this model`. Skip sending so the upstream applies its // own default (1). Matches the model-locked behavior of o1/o3/o4. if p.providerType == "kimi_coding" { skipTemp = true } if !skipTemp { body["temperature"] = v } } // reasoning_effort is OpenAI-specific; do not send to third-party OpenAI-compatible APIs. if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { if openAIModelSupportsReasoningEffort(model) { body[OptReasoningEffort] = level } } // Gemini (Google OpenAI-compat) accepts reasoning_effort mapped to thinking_config. // Without forwarding, Gemini 3 defaults to "high" thinking and consumes the entire // max_tokens budget, leaving no room for tool call arguments on small models. // Gate narrowly: apiBase contains "generativelanguage" OR model substring "gemini" // (covers OpenRouter / LiteLLM / Vertex proxies). if _, already := body[OptReasoningEffort]; !already && p.isGeminiRoute(model) { if level, ok := req.Options[OptThinkingLevel].(string); ok { if mapped, forward := mapGeminiReasoningEffort(level); forward { body[OptReasoningEffort] = mapped } } } // Ollama-specific: inject options.num_ctx to set the context window size. // Without this, Ollama defaults to a small context (often 2048) and returns // context-window errors on long conversations. // Priority: user-configured ollamaNumCtx > pre-queried /api/show value > 131072 default. // Also disable thinking by default to prevent bloated chain-of-thought responses // from models like qwq and deepseek-r1 which have thinking enabled by default. if p.isOllamaEndpoint() { numCtx := OllamaDefaultNumCtx numCtxSource := "default" if p.ollamaNumCtx != nil { numCtx = *p.ollamaNumCtx numCtxSource = "configured" } slog.Debug("ollama.request: injecting num_ctx into options", "provider", p.name, "model", model, "num_ctx", numCtx, "source", numCtxSource, ) body["options"] = map[string]any{ "num_ctx": numCtx, } if bodyBytes, err := json.Marshal(body); err == nil { raw := string(bodyBytes) if len(raw) > 500 { raw = raw[:500] + "..." } slog.Debug("ollama.request: final request body (first 500 chars)", "provider", p.name, "model", model, "body_prefix", raw) } // Thinking visibility: provider-level override (settings.thinking_enabled) // takes precedence; otherwise disable thinking by default (models like // qwq/deepseek-r1 have thinking on by default) unless the caller // explicitly requests a reasoning effort level. switch { case p.thinkingEnabled != nil: body["think"] = *p.thinkingEnabled default: if level, _ := req.Options[OptThinkingLevel].(string); level == "" || level == "off" { body["think"] = false } } } // DashScope-specific passthrough keys — never send to other OpenAI-compat hosts. if p.dashScopePassthroughKeys() { if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" && dashscopeThinkingModels[model] { body[OptEnableThinking] = true body[OptThinkingBudget] = dashscopeThinkingBudget(level) } if v, ok := req.Options[OptEnableThinking]; ok { body[OptEnableThinking] = v } if v, ok := req.Options[OptThinkingBudget]; ok { body[OptThinkingBudget] = v } } return body } // buildToolNameIndex returns a raw-ID → tool-name map drawn from every assistant // message's ToolCalls. Used at serialize time to populate role=tool wire messages // with their originating tool's name (required by Google Gemini OpenAI-compat shim). func buildToolNameIndex(msgs []Message) map[string]string { idx := map[string]string{} for _, m := range msgs { if m.Role != "assistant" { continue } for _, tc := range m.ToolCalls { if tc.ID != "" && tc.Name != "" { idx[tc.ID] = tc.Name } } } return idx } // isGeminiRoute returns true when this OpenAI-compat request targets Gemini, // either via the native Google endpoint or a proxy (OpenRouter, LiteLLM) that // routes by model string. Narrower than the supportsThoughtSignature gate — // we require explicit intent before forwarding reasoning_effort on proxies. func (p *OpenAIProvider) isGeminiRoute(model string) bool { if strings.Contains(strings.ToLower(p.apiBase), "generativelanguage") { return true } return strings.Contains(strings.ToLower(model), "gemini") } // mapGeminiReasoningEffort returns (value, shouldForward). Gemini 3 Preview // rejects "medium" with HTTP 400, so we map it to the nearest valid option. // "off" maps to "low" (the minimum effort accepted by all Gemini models via // OpenAI-compat). Forwarding is required because Gemini's default is "high", // which consumes the entire max_tokens budget on reasoning traces and leaves // no room for the response. Unknown values do not forward. func mapGeminiReasoningEffort(level string) (string, bool) { switch level { case "low", "minimal", "high": return level, true case "medium": return "high", true case "off": return "low", true default: return "", false } } // modelFamily strips provider prefixes (for example "openai/o3-mini") so capability // gates apply to the actual model family rather than the transport-specific wrapper. func modelFamily(model string) string { if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { return model[idx+1:] } return model } // openAIModelSupportsReasoningEffort is true when the Chat Completions request may include // the top-level "reasoning_effort" field (OpenAI o-series / GPT-5 family). // Other OpenAI-compatible hosts (Together, Groq, vLLM, etc.) often reject unknown fields with HTTP 400. func openAIModelSupportsReasoningEffort(model string) bool { if LookupReasoningCapability(model) != nil { return true } fam := strings.ToLower(modelFamily(model)) for _, prefix := range []string{"gpt-5", "o1", "o3", "o4"} { if strings.HasPrefix(fam, prefix) { return true } } return false } // buildToolsPayload serializes tools for the OpenAI-compat tools array. // - function tools → {"type":"function","function":{cleaned schema}} // - native tools (e.g. "image_generation") → {"type": t.Type} bare object // // Ordering is preserved. func buildToolsPayload(schemaProvider string, tools []ToolDefinition) []map[string]any { cleaned := CleanToolSchemas(schemaProvider, tools) out := make([]map[string]any, 0, len(cleaned)) for _, t := range cleaned { switch t.Type { case "function": if t.Function == nil { continue } params := t.Function.Parameters fn := map[string]any{ "name": t.Function.Name, "description": t.Function.Description, "parameters": params, } if t.Function.Strict != nil { fn["strict"] = *t.Function.Strict } out = append(out, map[string]any{ "type": "function", "function": fn, }) default: // Native provider tool — emit as bare {"type": X}. // Richer field serialization is deferred to later phases. out = append(out, map[string]any{ "type": t.Type, }) } } return out } // openAIWireAssistantReasoningContent is true when assistant message objects may include // "reasoning_content" (thinking replay). Narrow allowlist — most OpenAI-compat hosts reject it. func openAIWireAssistantReasoningContent(model string) bool { if openAIModelSupportsReasoningEffort(model) { return true } fam := strings.ToLower(modelFamily(model)) full := strings.ToLower(model) if strings.Contains(fam, "deepseek") || strings.Contains(full, "deepseek") { return true } if strings.Contains(fam, "kimi") || strings.Contains(full, "kimi") { return true } return false }