This commit is contained in:
2026-08-16 21:35:48 +02:00
parent 106f602232
commit 389608ea56
28 changed files with 2491 additions and 115 deletions
+59 -20
View File
@@ -256,11 +256,14 @@ type chatResponse struct {
} `json:"error"`
}
var errEmptyModelResponse = errors.New("empty model response")
var (
errEmptyModelResponse = errors.New("empty model response")
errLengthCappedResponse = errors.New("response truncated at max_tokens")
)
// maxTokensReasoningBudget is used when a capped completion returns empty
// content with finish_reason=length (reasoning models).
const maxTokensReasoningBudget = 4096
func isLengthBudgetErr(err error) bool {
return errors.Is(err, errEmptyModelResponse) || errors.Is(err, errLengthCappedResponse)
}
func (c *OpenAIClient) Complete(ctx context.Context, system, user string) (Completion, error) {
return c.CompleteWithOptions(ctx, system, user, CompleteOptions{})
@@ -300,16 +303,19 @@ func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user str
return comp, nil
}
lastErr = err
if errors.Is(err, errEmptyModelResponse) {
if maxTok <= 0 || maxTok < maxTokensReasoningBudget {
if maxTok < maxTokensReasoningBudget {
maxTok = maxTokensReasoningBudget
if isLengthBudgetErr(err) {
if maxTok <= 0 || maxTok < MaxTokensEnhanceRetry {
prev := maxTok
if maxTok < MaxTokensEnhanceRetry {
maxTok = MaxTokensEnhanceRetry
}
log.Printf("openai: length-cap retry model=%s prev_max_tokens=%d next_max_tokens=%d err=%s",
c.Model, prev, maxTok, TruncateError(err))
retryable = true
} else {
// Already at reasoning budget — another 240s call will not help.
// Already at enhance retry ceiling — another long call will not help.
return Completion{}, fmt.Errorf(
"openai empty response at max_tokens=%d for model %q (try a faster non-reasoning model): %w",
"openai length-capped at max_tokens=%d for model %q (prefer a faster non-reasoning product-enhance model): %w",
maxTok, c.Model, err)
}
}
@@ -488,34 +494,41 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp
stopWait := c.startWaitLogger("chat/completions", maxTokens)
res, err := c.HTTPClient.Do(req)
stopWait(err)
if err != nil {
stopWait(err)
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
return Completion{}, retryable, wrapped
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
stopWait(err)
wrapped, retryable := classifyOpenAITransportErr(c.Model, err)
return Completion{}, retryable, wrapped
}
var parsed chatResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return Completion{}, false, fmt.Errorf("openai decode: %w", err)
decErr := fmt.Errorf("openai decode: %w", err)
stopWait(decErr)
return Completion{}, false, decErr
}
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
msg := "rate limited or server error"
if parsed.Error != nil && parsed.Error.Message != "" {
msg = TruncateError(errors.New(parsed.Error.Message))
}
return Completion{}, true, errors.New(msg)
httpErr := errors.New(msg)
stopWait(httpErr)
return Completion{}, true, httpErr
}
if res.StatusCode >= 400 {
msg := fmt.Sprintf("openai http %d", res.StatusCode)
if parsed.Error != nil && parsed.Error.Message != "" {
msg = TruncateError(errors.New(parsed.Error.Message))
}
return Completion{}, false, errors.New(msg)
httpErr := errors.New(msg)
stopWait(httpErr)
return Completion{}, false, httpErr
}
text := ""
finishReason := ""
@@ -523,18 +536,44 @@ func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temp
finishReason = strings.TrimSpace(parsed.Choices[0].FinishReason)
text = SanitizeOutput(choiceMessageText(parsed.Choices[0].Message.Content, parsed.Choices[0].Message.ReasoningContent, parsed.Choices[0].Message.Reasoning))
}
usagePrompt := parsed.Usage.PromptTokens
usageOut := parsed.Usage.CompletionTokens
usageTotal := parsed.Usage.TotalTokens
canBump := maxTokens <= 0 || maxTokens < MaxTokensEnhanceRetry
lengthCapped := strings.EqualFold(finishReason, "length")
if text == "" {
// Reasoning models often return empty content when max_tokens cuts mid-thought.
// Only retry when CompleteWithOptions can still raise max_tokens.
canBump := maxTokens <= 0 || maxTokens < maxTokensReasoningBudget
retryable := strings.EqualFold(finishReason, "length") && canBump
// Never log HTTP-ok as ok=1 when content is empty (prod looked "successful" at 30272ms).
retryable := lengthCapped && canBump
emptyErr := fmt.Errorf("%w (finish_reason=%s max_tokens=%d prompt_tokens=%d completion_tokens=%d)",
errEmptyModelResponse, finishReason, maxTokens, usagePrompt, usageOut)
stopWait(emptyErr)
log.Printf("openai: chat content empty model=%s finish_reason=%s max_tokens=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d retryable=%t",
c.Model, finishReason, maxTokens, usagePrompt, usageOut, usageTotal, retryable)
return Completion{}, retryable, errEmptyModelResponse
}
if lengthCapped {
// Truncated JSON was previously treated as success → parse_failed → synthesize.
// Accept only when the truncated body still parses as a JSON object.
if _, err := ParseJSONObject(text); err != nil {
retryable := canBump
truncErr := fmt.Errorf("%w (finish_reason=length max_tokens=%d prompt_tokens=%d completion_tokens=%d content_runes=%d)",
errLengthCappedResponse, maxTokens, usagePrompt, usageOut, len([]rune(text)))
stopWait(truncErr)
log.Printf("openai: chat content truncated model=%s finish_reason=length max_tokens=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d content_runes=%d retryable=%t",
c.Model, maxTokens, usagePrompt, usageOut, usageTotal, len([]rune(text)), retryable)
return Completion{}, retryable, errLengthCappedResponse
}
}
stopWait(nil)
log.Printf("openai: chat content ok model=%s finish_reason=%s content_runes=%d prompt_tokens=%d completion_tokens=%d total_tokens=%d",
c.Model, finishReason, len([]rune(text)), usagePrompt, usageOut, usageTotal)
return Completion{
Text: text,
PromptTokens: parsed.Usage.PromptTokens,
OutputTokens: parsed.Usage.CompletionTokens,
TotalTokens: parsed.Usage.TotalTokens,
PromptTokens: usagePrompt,
OutputTokens: usageOut,
TotalTokens: usageTotal,
Model: parsed.Model,
Raw: map[string]any{
"model": parsed.Model,
@@ -678,7 +717,7 @@ func inventHeuristicDescription(system, user, name string) string {
name = "Product"
}
cat := labeledPromptValue(user, "category:")
if isPromptLabelTitle(cat) {
if isUnusableCategoryValue(cat, name) {
cat = ""
}
lang := languageCodeFromEnhancePrompt(system, user)