Files
nguyenha935andGoClaw Operator 41321251cb fix: infer text-encoded tool calls by schema (#1320)
Co-authored-by: GoClaw Operator <operator@goclaw>
2026-07-02 00:54:29 +07:00

224 lines
7.1 KiB
Go

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:<mime>;base64,<b64>).
// 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"]
}