package providers import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "regexp" "strconv" "strings" ) func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser, error) { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("%s: marshal request: %w", p.name, err) } // Ollama: route to native /api/chat so options.num_ctx is honored. // The OpenAI-compat shim at /v1/chat/completions silently ignores options.num_ctx. url := p.apiBase + p.chatPath if p.isOllamaEndpoint() { url = p.ollamaNativeURL() } httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("%s: create request: %w", p.name, err) } httpReq.Header.Set("Content-Type", "application/json") switch { case p.noAuthHeader: // Caller-supplied transport (e.g. Vertex oauth2.Transport) injects Authorization itself. case strings.Contains(strings.ToLower(p.apiBase), "azure.com"): httpReq.Header.Set("api-key", p.apiKey) default: prefix := p.authPrefix if prefix == "" { prefix = "Bearer " } httpReq.Header.Set("Authorization", prefix+p.apiKey) } // OpenRouter identification headers for rankings/analytics if p.siteURL != "" { httpReq.Header.Set("HTTP-Referer", p.siteURL) } if p.siteTitle != "" { httpReq.Header.Set("X-Title", p.siteTitle) } // Static per-provider headers (e.g. fixed User-Agent for kimi_coding). // Applied after the standard headers so providers can override them if needed. for k, v := range p.extraHeaders { httpReq.Header.Set(k, v) } resp, err := p.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("%s: request failed: %w", p.name, err) } if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() retryAfter := ParseRetryAfter(resp.Header.Get("Retry-After")) return nil, &HTTPError{ Status: resp.StatusCode, Body: fmt.Sprintf("%s: %s", p.name, string(respBody)), RetryAfter: retryAfter, } } return resp.Body, nil } func (p *OpenAIProvider) parseResponse(resp *openAIResponse, tools ...[]ToolDefinition) *ChatResponse { result := &ChatResponse{FinishReason: "stop"} var toolDefs []ToolDefinition if len(tools) > 0 { toolDefs = tools[0] } if len(resp.Choices) > 0 { msg := resp.Choices[0].Message result.Content = msg.Content result.Thinking = msg.ReasoningContent if result.Thinking == "" { result.Thinking = msg.Reasoning } result.FinishReason = resp.Choices[0].FinishReason for _, tc := range msg.ToolCalls { args := make(map[string]any) var parseErr string if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil && tc.Function.Arguments != "" { slog.Warn("openai: failed to parse tool call arguments", "tool", tc.Function.Name, "raw_len", len(tc.Function.Arguments), "error", err) parseErr = fmt.Sprintf("malformed JSON (%d chars): %v", len(tc.Function.Arguments), err) } call := ToolCall{ ID: tc.ID, Name: strings.TrimSpace(tc.Function.Name), Arguments: args, ParseError: parseErr, } if tc.Function.ThoughtSignature != "" { call.Metadata = map[string]string{"thought_signature": tc.Function.ThoughtSignature} } result.ToolCalls = append(result.ToolCalls, call) } // Only override finish_reason when response wasn't truncated. // Preserve "length" so agent loop can detect truncation and retry. if len(result.ToolCalls) > 0 && result.FinishReason != "length" { result.FinishReason = "tool_calls" } // Decode images[] from the response message into ChatResponse.Images. // Each entry carries a data URL (data:;base64,). // Malformed entries are skipped with a warning to avoid crashing on partial responses. for _, img := range msg.Images { mimeType, b64Data, err := parseDataURL(img.ImageURL.URL) if err != nil { slog.Warn("openai: skipping malformed image data URL", "type", img.Type, "url_len", len(img.ImageURL.URL), "error", err) continue } result.Images = append(result.Images, ImageContent{ MimeType: mimeType, Data: b64Data, }) } normalized := normalizeControlOutput(result.Content, result.Thinking, toolDefs) result.Content = normalized.Content result.Thinking = normalized.Thinking result.ToolCalls = append(result.ToolCalls, normalized.ToolCalls...) if len(result.ToolCalls) > 0 && result.FinishReason != "length" { result.FinishReason = "tool_calls" } } if resp.Usage != nil { result.Usage = &Usage{ PromptTokens: resp.Usage.PromptTokens, CompletionTokens: resp.Usage.CompletionTokens, TotalTokens: resp.Usage.TotalTokens, RequestCount: 1, } if resp.Usage.PromptTokensDetails != nil { result.Usage.CacheReadTokens = resp.Usage.PromptTokensDetails.CachedTokens result.Usage.CacheCreationTokens = resp.Usage.PromptTokensDetails.CacheWriteTokens + resp.Usage.PromptTokensDetails.CacheCreationInputTokens result.Usage.PromptTokensIncludeCachedSegments = true } if resp.Usage.CompletionTokensDetails != nil && resp.Usage.CompletionTokensDetails.ReasoningTokens > 0 { result.Usage.ThinkingTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens } if resp.Usage.ServerToolUse != nil { result.Usage.WebSearchCount = resp.Usage.ServerToolUse.WebSearchRequests } } return result } func emitNormalizedControlChunk(result *ChatResponse, normalized controlOutput, stripThinking bool, onChunk func(StreamChunk)) { if normalized.Thinking != "" && !stripThinking { result.Thinking = joinThinking(result.Thinking, normalized.Thinking) if onChunk != nil { onChunk(StreamChunk{Thinking: normalized.Thinking}) } } if normalized.Content != "" { result.Content += normalized.Content if onChunk != nil { onChunk(StreamChunk{Content: normalized.Content}) } } } // maxTokensLimitRe matches "supports at most N completion tokens" from OpenAI 400 errors. var maxTokensLimitRe = regexp.MustCompile(`supports at most (\d+) completion tokens`) // clampMaxTokensFromError checks if an error is a 400 "max_tokens is too large" rejection. // If so, it parses the model's stated limit, clamps the body's max_tokens/max_completion_tokens, // and returns true so the caller can retry. func clampMaxTokensFromError(err error, body map[string]any) bool { var httpErr *HTTPError if !errors.As(err, &httpErr) || httpErr.Status != http.StatusBadRequest { return false } if !strings.Contains(httpErr.Body, "max_tokens") || !strings.Contains(httpErr.Body, "too large") { return false } matches := maxTokensLimitRe.FindStringSubmatch(httpErr.Body) if len(matches) < 2 { return false } limit, parseErr := strconv.Atoi(matches[1]) if parseErr != nil || limit <= 0 { return false } // Clamp whichever key is present if _, ok := body["max_completion_tokens"]; ok { body["max_completion_tokens"] = limit } else { body["max_tokens"] = limit } return true } // clampedLimit returns the clamped max_tokens or max_completion_tokens value for logging. func clampedLimit(body map[string]any) any { if v, ok := body["max_completion_tokens"]; ok { return v } return body["max_tokens"] }