mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
fix(providers): auto-clamp max_tokens on model rejection (#267)
* fix(providers): auto-clamp max_tokens on model rejection + fix verify for reasoning models When OpenAI-compat models reject max_tokens as too large (e.g. gpt-3.5-turbo supports 4096 but we send 8192), parse the model's stated limit from the 400 error, clamp the value, and retry once. This fixes agent creation for models with lower output token limits without hardcoding model names. Also increase the provider verify endpoint's max_tokens from 1 to 50 so reasoning models (gpt-5, o-series) have enough headroom for internal reasoning during the check call. Closes #248, closes #245 * refactor(providers): extract chat retry closure + fix clamp log key - Extract duplicate retry closure into chatRequestFn() to follow DRY - Fix slog logging wrong key: body["max_tokens"] was nil for reasoning models that use max_completion_tokens — now uses clampedLimit() helper - Remove unnecessary _ = resp in provider verify endpoint --------- Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
1 parent
74cf7e52f5
commit
23d0b5eb0b
2 files changed
+76
-4
No files matched your search
@@ -104,14 +104,14 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
|
||||
},
|
||||
Model: req.Model,
|
||||
Options: map[string]any{
|
||||
"max_tokens": 1,
|
||||
// Use a small but safe value — reasoning models need headroom beyond 1 token.
|
||||
"max_tokens": 50,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -79,7 +82,25 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRespon
|
||||
model := p.resolveModel(req.Model)
|
||||
body := p.buildRequestBody(model, req, false)
|
||||
|
||||
return RetryDo(ctx, p.retryConfig, func() (*ChatResponse, error) {
|
||||
chatFn := p.chatRequestFn(ctx, body)
|
||||
|
||||
resp, err := RetryDo(ctx, p.retryConfig, chatFn)
|
||||
|
||||
// Auto-clamp max_tokens and retry once if the model rejects the value
|
||||
if err != nil {
|
||||
if clamped := clampMaxTokensFromError(err, body); clamped {
|
||||
slog.Info("max_tokens clamped, retrying", "model", model, "limit", clampedLimit(body))
|
||||
return RetryDo(ctx, p.retryConfig, chatFn)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// chatRequestFn returns a closure that performs a single non-streaming chat request.
|
||||
// Shared between initial attempt and post-clamp retry to avoid duplication.
|
||||
func (p *OpenAIProvider) chatRequestFn(ctx context.Context, body map[string]any) func() (*ChatResponse, error) {
|
||||
return func() (*ChatResponse, error) {
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,7 +113,7 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRespon
|
||||
}
|
||||
|
||||
return p.parseResponse(&oaiResp), nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
|
||||
@@ -103,6 +124,16 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChun
|
||||
respBody, err := RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) {
|
||||
return p.doRequest(ctx, body)
|
||||
})
|
||||
|
||||
// Auto-clamp max_tokens and retry once if the model rejects the value
|
||||
if err != nil {
|
||||
if clamped := clampMaxTokensFromError(err, body); clamped {
|
||||
slog.Info("max_tokens clamped, retrying stream", "model", model, "limit", clampedLimit(body))
|
||||
respBody, err = RetryDo(ctx, p.retryConfig, func() (io.ReadCloser, error) {
|
||||
return p.doRequest(ctx, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -438,3 +469,44 @@ func (p *OpenAIProvider) parseResponse(resp *openAIResponse) *ChatResponse {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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"]
|
||||
}
|
||||
Reference in new issue
Block a user