Skip to content

Commit fabbddd

Browse files
authored
fix(llm): OpenAI provider compatibility for caching, temperature, and tool calls (#98)
* fix(llm): OpenAI provider compatibility for caching, temperature, and tool calls Three request-shape bugs surfaced when running against the real OpenAI API (DeepSeek tolerates all three, which is why they went unnoticed): 1. Anthropic prompt-caching shape sent to every provider. With prompt_caching enabled, the loop moved the system prompt into the Anthropic-only top-level "system" field and added cache_control markers; OpenAI rejects this with 400 unknown_parameter. Markers are now only applied when the endpoint is Anthropic (new Client.IsAnthropic). OpenAI and DeepSeek cache automatically. 2. temperature:0 sent to reasoning models. odek defaults to temperature 0 for determinism, but OpenAI reasoning models (o1/o3/o4, gpt-5 series) only accept the default (1) and answer 400 unsupported_value. The field is now omitted for those models; behavior for all other providers/models is unchanged. 3. Function tools + reasoning_effort rejected by gpt-5.6 models. gpt-5.6-luna/sol reject tools combined with any reasoning_effort other than "none" — and their default effort is not "none", so omitting the field still 400s. The client now learns the constraint from the 400 (atomic flag), retries once with effort "none", and pins it for subsequent tool-bearing calls; tool-less calls keep the configured effort. Verified live against api.openai.com (gpt-4o-mini, gpt-5-nano, gpt-5.6-luna, gpt-5.6-sol — basic runs, tool round-trips, automatic prompt caching) and regression-checked against DeepSeek v4 Flash (native cache metrics and temperature:0 default intact). Regression tests: TestClient_IsAnthropic, TestCall_OmitsTemperatureForReasoningModels, TestCall_LearnsReasoningEffortNoneWithTools, TestEngine_PromptCaching_NonAnthropicSkipsMarkers. * fix(llm): map thinking enabled/disabled per provider instead of always sending the Anthropic object The thinking object ({"type":"enabled"/"disabled"}) was sent unconditionally; OpenAI rejects unknown top-level parameters with a 400, so thinking: disabled (and enabled) was broken against OpenAI — same family as the system-field bug fixed earlier in this branch. The shaping logic now lives in an extracted, directly testable buildCallParams method: - Anthropic/DeepSeek (new sendsThinkingObject gate): unchanged, thinking object sent as before. - OpenAI reasoning models (o1/o3/o4, gpt-5 series): enabled maps to reasoning_effort "high", disabled to "none" (verified accepted on the gpt-5 series); temperature stays omitted per the forbidlist. - Non-reasoning models (e.g. gpt-4o): no thinking capability exists, so both fields are omitted and temperature is honored as configured. The two existing thinking tests asserted the old unconditional behavior through an httptest server whose URL is never a recognized provider; they now exercise buildCallParams directly. New table test TestBuildCallParams_ThinkingMapping covers all provider/model/thinking combinations.
1 parent 6a90bc4 commit fabbddd

4 files changed

Lines changed: 365 additions & 74 deletions

File tree

internal/llm/client.go

Lines changed: 124 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"strconv"
1212
"strings"
13+
"sync/atomic"
1314
"time"
1415

1516
"github.com/BackendStack21/odek/internal/transport"
@@ -25,6 +26,11 @@ type Client struct {
2526
MaxTokens int // max output tokens (0 = provider default)
2627
Temperature float64 // 0 = use provider default, <0 = omit from request
2728
http *http.Client
29+
30+
// forceNoneEffort is learned at runtime: set when the provider rejects
31+
// reasoning_effort combined with function tools (e.g. gpt-5.6-luna),
32+
// so subsequent calls pin effort to "none" without a failed round-trip.
33+
forceNoneEffort atomic.Bool
2834
}
2935

3036
// maxResponseSize limits the LLM response body read to prevent DoS/OOM.
@@ -54,8 +60,44 @@ func NewWithMaxTokens(baseURL, apiKey, model, thinking string, thinkingBudget in
5460
}
5561
}
5662

63+
// IsAnthropic reports whether the client's base URL targets the Anthropic
64+
// API. Anthropic-specific request features (the top-level "system" field,
65+
// cache_control markers) must only be sent when this is true — other
66+
// providers reject them (OpenAI answers 400 unknown_parameter).
67+
func (c *Client) IsAnthropic() bool {
68+
return strings.Contains(c.BaseURL, "anthropic")
69+
}
70+
71+
// sendsThinkingObject reports whether the provider accepts the
72+
// Anthropic-style "thinking" request object. Anthropic requires it for
73+
// extended thinking and DeepSeek supports it natively (deepseek-reasoner);
74+
// other providers (OpenAI and compatibles) reject unknown top-level
75+
// parameters, so thinking intent must be mapped to reasoning_effort instead.
76+
func (c *Client) sendsThinkingObject() bool {
77+
return c.IsAnthropic() || strings.Contains(c.BaseURL, "deepseek")
78+
}
79+
80+
// modelForbidsTemperature reports whether the model rejects an explicit
81+
// temperature parameter. OpenAI reasoning models (o1/o3/o4 families and the
82+
// gpt-5 series) only accept the default temperature (1); sending any other
83+
// value — including odek's deterministic default 0 — returns a 400
84+
// "unsupported_value" error. Matching is model-name-based and
85+
// provider-agnostic, since OpenAI-compatible proxies serving these model
86+
// IDs enforce the same constraint.
87+
func modelForbidsTemperature(model string) bool {
88+
m := strings.ToLower(model)
89+
for _, prefix := range []string{"o1", "o3", "o4", "gpt-5"} {
90+
if strings.HasPrefix(m, prefix) {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
5797
// CacheControl marks a message or system block as cacheable by Anthropic.
58-
// Providers that don't support it (OpenAI, DeepSeek) silently ignore the field.
98+
// Only send it to Anthropic endpoints: OpenAI rejects Anthropic-style
99+
// request shapes with a 400 (e.g. the top-level "system" field), so the
100+
// loop only applies cache markers when Client.IsAnthropic reports true.
59101
type CacheControl struct {
60102
Type string `json:"type"` // "ephemeral"
61103
}
@@ -154,7 +196,8 @@ var toolChoiceNone = "none"
154196
//
155197
// Returns the updated messages and a System field (populated if the system
156198
// message was moved out of the messages array for Anthropic compatibility).
157-
// Providers that don't support prompt caching silently ignore these fields.
199+
// Callers must only use this when the target provider is Anthropic
200+
// (see Client.IsAnthropic) — OpenAI rejects the resulting request shape.
158201
func ApplyCacheMarkers(messages []Message) ([]Message, []SystemBlock) {
159202
var systemBlocks []SystemBlock
160203
annotated := make([]Message, 0, len(messages))
@@ -238,12 +281,11 @@ func (c *Client) SimpleCall(ctx context.Context, systemPrompt, userPrompt string
238281
return raw.Choices[0].Message.Content, nil
239282
}
240283

241-
// Call sends a chat completion request and returns the result.
242-
// systemBlocks is optional — pass nil for providers that don't support
243-
// the separate System field (OpenAI, DeepSeek). When non-nil, the system
244-
// prompt is sent in the "system" field instead of as a system message in
245-
// the messages array (Anthropic format for prompt caching).
246-
func (c *Client) Call(ctx context.Context, messages []Message, systemBlocks []SystemBlock, tools []ToolDef) (*CallResult, error) {
284+
// buildCallParams assembles the request body for a Call, mapping the
285+
// thinking configuration onto whatever shape the target provider accepts:
286+
// the Anthropic-style "thinking" object for Anthropic/DeepSeek,
287+
// reasoning_effort for OpenAI reasoning models, and nothing otherwise.
288+
func (c *Client) buildCallParams(messages []Message, systemBlocks []SystemBlock, tools []ToolDef) CallParams {
247289
body := CallParams{
248290
Model: c.Model,
249291
Messages: messages,
@@ -255,45 +297,104 @@ func (c *Client) Call(ctx context.Context, messages []Message, systemBlocks []Sy
255297

256298
switch c.Thinking {
257299
case "enabled":
258-
// Anthropic requires budget_tokens when enabling thinking.
259-
// 5000 is a safe default: leaves ample room for the text response
260-
// even on models with 8K max output (e.g. Claude Haiku).
261-
// DeepSeek silently ignores the field.
262-
budget := c.ThinkingBudget
263-
if budget <= 0 {
264-
budget = 5000
300+
if c.sendsThinkingObject() {
301+
// Anthropic requires budget_tokens when enabling thinking.
302+
// 5000 is a safe default: leaves ample room for the text response
303+
// even on models with 8K max output (e.g. Claude Haiku).
304+
// DeepSeek silently ignores the field.
305+
budget := c.ThinkingBudget
306+
if budget <= 0 {
307+
budget = 5000
308+
}
309+
body.Thinking = &ThinkingConfig{Type: "enabled", BudgetTokens: budget}
310+
// Anthropic also requires temperature=1 when thinking is enabled.
311+
// Force it regardless of the configured temperature to avoid a 400.
312+
one := float64(1)
313+
body.Temperature = &one
314+
} else if modelForbidsTemperature(c.Model) {
315+
// OpenAI reasoning models have no "thinking" object; the closest
316+
// mapping for enabled extended thinking is maximum effort.
317+
// Temperature stays omitted — only the provider default is accepted.
318+
body.ReasoningEffort = "high"
319+
} else if c.Temperature >= 0 {
320+
// Non-reasoning models (e.g. gpt-4o) have no thinking to enable;
321+
// just honor the configured temperature.
322+
body.Temperature = &c.Temperature
265323
}
266-
body.Thinking = &ThinkingConfig{Type: "enabled", BudgetTokens: budget}
267-
// Anthropic also requires temperature=1 when thinking is enabled.
268-
// Force it regardless of the configured temperature to avoid a 400.
269-
one := float64(1)
270-
body.Temperature = &one
271324
case "disabled":
272-
body.Thinking = &ThinkingConfig{Type: "disabled"}
273-
if c.Temperature >= 0 {
325+
if c.sendsThinkingObject() {
326+
body.Thinking = &ThinkingConfig{Type: "disabled"}
327+
} else if modelForbidsTemperature(c.Model) {
328+
// OpenAI reasoning models: disabling thinking maps to effort
329+
// "none" (accepted on the gpt-5 series). Non-reasoning models
330+
// have nothing to disable, so the field is omitted entirely.
331+
body.ReasoningEffort = "none"
332+
}
333+
if c.Temperature >= 0 && !modelForbidsTemperature(c.Model) {
274334
body.Temperature = &c.Temperature
275335
}
276336
default:
277-
if c.Temperature >= 0 {
337+
if c.Temperature >= 0 && !modelForbidsTemperature(c.Model) {
278338
body.Temperature = &c.Temperature
279339
}
280340
if c.Thinking == "low" || c.Thinking == "medium" || c.Thinking == "high" {
281341
body.ReasoningEffort = c.Thinking
282342
}
283343
}
284344

345+
// Some models (e.g. gpt-5.6-luna) reject function tools combined with
346+
// any reasoning_effort other than "none" on /chat/completions — and
347+
// their DEFAULT effort is not "none", so merely omitting the field is
348+
// not enough. Once that constraint has been learned from a 400 (see
349+
// Call), pin effort to "none" whenever tools are present.
350+
if c.forceNoneEffort.Load() && len(tools) > 0 {
351+
body.ReasoningEffort = "none"
352+
}
353+
354+
return body
355+
}
356+
357+
// Call sends a chat completion request and returns the result.
358+
// systemBlocks is optional — pass nil for providers that don't support
359+
// the separate System field (OpenAI, DeepSeek). When non-nil, the system
360+
// prompt is sent in the "system" field instead of as a system message in
361+
// the messages array (Anthropic format for prompt caching).
362+
func (c *Client) Call(ctx context.Context, messages []Message, systemBlocks []SystemBlock, tools []ToolDef) (*CallResult, error) {
363+
body := c.buildCallParams(messages, systemBlocks, tools)
364+
285365
reqBytes, err := json.Marshal(body)
286366
if err != nil {
287367
return nil, fmt.Errorf("llm: marshal request: %w", err)
288368
}
289369

290370
respBytes, err := c.postChatWithRetry(ctx, reqBytes)
371+
if err != nil && len(tools) > 0 && !c.forceNoneEffort.Load() && reasoningEffortRejected(err) {
372+
// Learn the constraint once, then every later call sends
373+
// reasoning_effort "none" directly (no more failed round-trips).
374+
c.forceNoneEffort.Store(true)
375+
body.ReasoningEffort = "none"
376+
reqBytes, mErr := json.Marshal(body)
377+
if mErr != nil {
378+
return nil, fmt.Errorf("llm: marshal request: %w", mErr)
379+
}
380+
respBytes, err = c.postChatWithRetry(ctx, reqBytes)
381+
}
291382
if err != nil {
292383
return nil, err
293384
}
294385
return parseResponse(respBytes)
295386
}
296387

388+
// reasoningEffortRejected reports whether err is a 400 whose provider
389+
// response names reasoning_effort as the offending parameter.
390+
func reasoningEffortRejected(err error) bool {
391+
if err == nil {
392+
return false
393+
}
394+
msg := err.Error()
395+
return strings.Contains(msg, "400") && strings.Contains(msg, `"reasoning_effort"`)
396+
}
397+
297398
// postChatWithRetry POSTs reqBytes to /chat/completions and returns the raw 200
298399
// response body, retrying transient network errors and retryable HTTP statuses
299400
// (429, 502, 503, 504) with exponential backoff. Shared by every chat call so

0 commit comments

Comments
 (0)