diff --git a/apps/api/internal/aiprovider/catalog.go b/apps/api/internal/aiprovider/catalog.go index 243ffa6..ac362ad 100644 --- a/apps/api/internal/aiprovider/catalog.go +++ b/apps/api/internal/aiprovider/catalog.go @@ -30,8 +30,17 @@ var PopularCatalog = []PopularProvider{ Name: "openai", Label: "OpenAI", BaseURL: "https://api.openai.com/v1", - DefaultModel: "gpt-4o-mini", - Models: []string{"gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"}, + DefaultModel: "gpt-5.6-luna", + Models: []string{ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-5.6", + "gpt-4o-mini", + "gpt-4o", + "gpt-4.1-mini", + "gpt-4.1", + }, }, { Name: "google", @@ -65,8 +74,8 @@ var PopularCatalog = []PopularProvider{ Name: "openrouter", Label: "OpenRouter", BaseURL: "https://openrouter.ai/api/v1", - DefaultModel: "openai/gpt-4o-mini", - Models: []string{"openai/gpt-4o-mini", "anthropic/claude-sonnet-4", "google/gemini-2.0-flash-001"}, + DefaultModel: "openai/gpt-5.6-luna", + Models: []string{"openai/gpt-5.6-luna", "openai/gpt-5.6-terra", "openai/gpt-4o-mini", "anthropic/claude-sonnet-4", "google/gemini-2.0-flash-001"}, }, } diff --git a/apps/api/internal/processing/llm_json.go b/apps/api/internal/processing/llm_json.go index e83c927..a978cf7 100644 --- a/apps/api/internal/processing/llm_json.go +++ b/apps/api/internal/processing/llm_json.go @@ -15,24 +15,33 @@ const ( // spend thousands of tokens in reasoning_content before message.content. // Formula HTML JSON often needs a large completion budget; 4096 hit // finish_reason=length with empty/truncated content in live enhance. - MaxTokensEnhance = 16384 + // GPT-5.6 max_completion_tokens includes hidden reasoning tokens — keep + // this high so multi-section HTML descriptions are not truncated. + MaxTokensEnhance = 24576 // MaxTokensEnhanceRetry is the one-shot length-cap bump ceiling used by // OpenAIClient when finish_reason=length yields empty or unparseable JSON. - MaxTokensEnhanceRetry = 32768 + MaxTokensEnhanceRetry = 40960 MaxTokensSEO = 180 MaxTokensCampaign = 650 - MaxProductDescRunes = 400 - MaxAttrKeys = 10 - MaxAttrValueRunes = 60 - MaxBrandInjectRunes = 500 - MaxCampaignProducts = 8 - MaxCampaignNameRunes = 80 + // MaxProductDescRunes caps source description text fed into enhance prompts. + // Kept large enough for existing long/HTML descriptions so the model can + // rewrite factually instead of inventing from a truncated stub. + MaxProductDescRunes = 3500 + MaxAttrKeys = 10 + MaxAttrValueRunes = 60 + MaxBrandInjectRunes = 500 + MaxCampaignProducts = 8 + MaxCampaignNameRunes = 80 ) // CompleteOptions tunes a single chat completion for structured tasks. type CompleteOptions struct { MaxTokens int - Temperature float64 // 0 → client default (≤0.3 for structured) + Temperature float64 // 0 → client default (≤0.3 for structured); omitted on GPT-5/o-series + // ReasoningEffort is Chat Completions reasoning_effort for GPT-5 / o-series. + // Empty → auto: "none" for tiny/probe budgets, "low" for content-heavy calls + // (long formula HTML) so visible tokens are not eaten by deep reasoning. + ReasoningEffort string } // CompleterWithOptions is optional; OpenAIClient implements it. @@ -205,7 +214,9 @@ func CompactBrandPrompt(block string) string { return truncateRunes(SanitizeText(block), MaxBrandInjectRunes) } -// ProductEnhanceUser builds a short user prompt for title/description enhance. +// ProductEnhanceUser builds the compact user prompt for title/description enhance. +// Source description is capped at MaxProductDescRunes so long/HTML inputs stay available +// for factual rewrite (not invented from a short stub). func ProductEnhanceUser(category, name, description string, attrs map[string]any) string { var b strings.Builder b.WriteString("Category: ") diff --git a/apps/api/internal/processing/llm_json_test.go b/apps/api/internal/processing/llm_json_test.go index 41d1e65..cddaca2 100644 --- a/apps/api/internal/processing/llm_json_test.go +++ b/apps/api/internal/processing/llm_json_test.go @@ -109,11 +109,23 @@ func TestCompleteJSON_returnsRetryError(t *testing.T) { } func TestProductEnhanceUser_truncates(t *testing.T) { - long := strings.Repeat("x", 2000) + long := strings.Repeat("x", MaxProductDescRunes+500) u := ProductEnhanceUser("Cat", "Name", long, map[string]any{"brand": "B"}) - if len([]rune(u)) > 900 { + // Category/Name/Attrs overhead + MaxProductDescRunes of description. + if len([]rune(u)) > MaxProductDescRunes+200 { t.Fatalf("user too long: %d", len([]rune(u))) } + descIdx := strings.Index(u, "Description: ") + if descIdx < 0 { + t.Fatalf("missing Description: in %q", u) + } + descPart := u[descIdx+len("Description: "):] + if i := strings.Index(descPart, "\n"); i >= 0 { + descPart = descPart[:i] + } + if len([]rune(descPart)) > MaxProductDescRunes { + t.Fatalf("description runes=%d want <= %d", len([]rune(descPart)), MaxProductDescRunes) + } if !strings.Contains(u, "brand") { t.Fatalf("%s", u) } diff --git a/apps/api/internal/processing/openai.go b/apps/api/internal/processing/openai.go index 5137ee5..4f44baf 100644 --- a/apps/api/internal/processing/openai.go +++ b/apps/api/internal/processing/openai.go @@ -223,13 +223,6 @@ func classifyOpenAITransportErr(model string, err error) (error, bool) { return err, true } -type chatRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` -} - type chatMessage struct { Role string `json:"role"` Content string `json:"content"` @@ -283,6 +276,7 @@ func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user str temp = 0.3 } maxTok := opts.MaxTokens + effort := opts.ReasoningEffort var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { @@ -298,7 +292,7 @@ func (c *OpenAIClient) CompleteWithOptions(ctx context.Context, system, user str if err := c.waitRate(ctx); err != nil { return Completion{}, err } - comp, retryable, err := c.doComplete(ctx, system, user, temp, maxTok) + comp, retryable, err := c.doComplete(ctx, system, user, temp, maxTok, effort) if err == nil { return comp, nil } @@ -471,16 +465,11 @@ func (c *OpenAIClient) doEmbed(ctx context.Context, texts []string) ([][]float32 return out, false, nil } -func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temperature float64, maxTokens int) (Completion, bool, error) { - reqBody := chatRequest{ - Model: c.Model, - Messages: []chatMessage{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - }, - Temperature: temperature, - MaxTokens: maxTokens, - } +func (c *OpenAIClient) doComplete(ctx context.Context, system, user string, temperature float64, maxTokens int, reasoningEffort string) (Completion, bool, error) { + reqBody := buildChatCompletionBody(c.Model, []chatMessage{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, temperature, maxTokens, reasoningEffort) body, err := json.Marshal(reqBody) if err != nil { return Completion{}, false, err diff --git a/apps/api/internal/processing/openai_chat_params.go b/apps/api/internal/processing/openai_chat_params.go new file mode 100644 index 0000000..ea8bb16 --- /dev/null +++ b/apps/api/internal/processing/openai_chat_params.go @@ -0,0 +1,97 @@ +package processing + +import ( + "strings" +) + +// openAIChatUsesMaxCompletionTokens reports models that reject legacy max_tokens +// on Chat Completions (o-series + GPT-5 family including GPT-5.6). +// See https://developers.openai.com/api/reference/resources/chat +func openAIChatUsesMaxCompletionTokens(model string) bool { + return openAIReasoningChatModel(model) +} + +// openAIChatOmitsCustomTemperature reports models that reject non-default +// temperature (only the API default of 1 is accepted, if temperature is sent). +func openAIChatOmitsCustomTemperature(model string) bool { + return openAIReasoningChatModel(model) +} + +// openAIChatSupportsReasoningEffort reports Chat Completions models that accept +// top-level reasoning_effort (Responses API uses reasoning.effort instead). +func openAIChatSupportsReasoningEffort(model string) bool { + return openAIReasoningChatModel(model) +} + +// openAIReasoningChatModel matches OpenAI reasoning / GPT-5(+).5/5.6 chat models +// and common snapshot aliases (e.g. gpt-5.6-luna-2026-…). +func openAIReasoningChatModel(model string) bool { + m := strings.ToLower(strings.TrimSpace(model)) + if m == "" { + return false + } + // Strip provider prefixes used by OpenRouter-style gateways. + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + switch { + case strings.HasPrefix(m, "o1"), + strings.HasPrefix(m, "o3"), + strings.HasPrefix(m, "o4"), + strings.HasPrefix(m, "gpt-5"), + strings.HasPrefix(m, "chatgpt-o"): + return true + default: + return false + } +} + +// defaultReasoningEffort picks Chat Completions reasoning_effort when unset. +// Long formula-HTML enhance uses "low" so max_completion_tokens prefers visible +// HTML over deep hidden reasoning. Tiny probes use "none" for speed/cost. +func defaultReasoningEffort(model string, maxTokens int, explicit string) string { + explicit = strings.ToLower(strings.TrimSpace(explicit)) + if explicit != "" { + return explicit + } + if !openAIChatSupportsReasoningEffort(model) { + return "" + } + if maxTokens <= 0 || maxTokens < 512 { + return "none" + } + return "low" +} + +// chatCompletionBody is the Chat Completions JSON body. Temperature is a pointer +// so reasoning models can omit it entirely (omitempty). +type chatCompletionBody struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` +} + +// buildChatCompletionBody maps CompleteOptions onto the correct Chat Completions +// fields for classic vs GPT-5 / o-series models. +func buildChatCompletionBody(model string, messages []chatMessage, temperature float64, maxTokens int, reasoningEffort string) chatCompletionBody { + body := chatCompletionBody{ + Model: model, + Messages: messages, + } + if openAIChatUsesMaxCompletionTokens(model) { + if maxTokens > 0 { + body.MaxCompletionTokens = maxTokens + } + } else if maxTokens > 0 { + body.MaxTokens = maxTokens + } + if !openAIChatOmitsCustomTemperature(model) { + t := temperature + body.Temperature = &t + } + body.ReasoningEffort = defaultReasoningEffort(model, maxTokens, reasoningEffort) + return body +} diff --git a/apps/api/internal/processing/openai_chat_params_test.go b/apps/api/internal/processing/openai_chat_params_test.go new file mode 100644 index 0000000..06f1715 --- /dev/null +++ b/apps/api/internal/processing/openai_chat_params_test.go @@ -0,0 +1,123 @@ +package processing + +import ( + "encoding/json" + "testing" +) + +func TestOpenAIReasoningChatModel(t *testing.T) { + t.Parallel() + cases := []struct { + model string + want bool + }{ + {"gpt-5.6-luna", true}, + {"gpt-5.6-terra", true}, + {"gpt-5.6-sol", true}, + {"gpt-5.6", true}, + {"GPT-5.4", true}, + {"openai/gpt-5.6-luna", true}, + {"o1-mini", true}, + {"o3", true}, + {"o4-mini", true}, + {"gpt-4o-mini", false}, + {"gpt-4.1", false}, + {"mock-llm", false}, + {"", false}, + } + for _, tc := range cases { + if got := openAIReasoningChatModel(tc.model); got != tc.want { + t.Fatalf("openAIReasoningChatModel(%q)=%v want %v", tc.model, got, tc.want) + } + } +} + +func TestBuildChatCompletionBody_gpt56Luna(t *testing.T) { + t.Parallel() + body := buildChatCompletionBody("gpt-5.6-luna", []chatMessage{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "ping"}, + }, 0.2, MaxTokensEnhance, "low") + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["temperature"]; ok { + t.Fatalf("temperature must be omitted for gpt-5.6-luna, got %v", m["temperature"]) + } + if _, ok := m["max_tokens"]; ok { + t.Fatalf("max_tokens must be omitted for gpt-5.6-luna, got %v", m["max_tokens"]) + } + gotMax, _ := m["max_completion_tokens"].(float64) + if int(gotMax) != MaxTokensEnhance { + t.Fatalf("max_completion_tokens=%v want %d", gotMax, MaxTokensEnhance) + } + if m["reasoning_effort"] != "low" { + t.Fatalf("reasoning_effort=%v want low", m["reasoning_effort"]) + } +} + +func TestBuildChatCompletionBody_classicModel(t *testing.T) { + t.Parallel() + body := buildChatCompletionBody("gpt-4o-mini", []chatMessage{ + {Role: "user", Content: "hi"}, + }, 0.2, 350, "") + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["max_completion_tokens"]; ok { + t.Fatalf("max_completion_tokens unexpected for classic model: %v", m) + } + gotMax, _ := m["max_tokens"].(float64) + if int(gotMax) != 350 { + t.Fatalf("max_tokens=%v want 350", gotMax) + } + gotTemp, _ := m["temperature"].(float64) + if gotTemp != 0.2 { + t.Fatalf("temperature=%v want 0.2", gotTemp) + } + if _, ok := m["reasoning_effort"]; ok { + t.Fatalf("reasoning_effort unexpected for classic model: %v", m) + } +} + +func TestBuildChatCompletionBody_probeDefaultsNoneEffort(t *testing.T) { + t.Parallel() + body := buildChatCompletionBody("gpt-5.6-luna", []chatMessage{ + {Role: "user", Content: "ping"}, + }, 0.2, 0, "") + if body.ReasoningEffort != "none" { + t.Fatalf("reasoning_effort=%q want none for probe", body.ReasoningEffort) + } + if body.MaxCompletionTokens != 0 || body.MaxTokens != 0 { + t.Fatalf("expected no token cap on probe, got %+v", body) + } + if body.Temperature != nil { + t.Fatalf("temperature must be nil for probe on gpt-5.6") + } +} + +func TestDefaultReasoningEffort(t *testing.T) { + t.Parallel() + if got := defaultReasoningEffort("gpt-5.6-luna", MaxTokensEnhance, ""); got != "low" { + t.Fatalf("enhance default=%q", got) + } + if got := defaultReasoningEffort("gpt-5.6-luna", 0, ""); got != "none" { + t.Fatalf("probe default=%q", got) + } + if got := defaultReasoningEffort("gpt-5.6-luna", 100, "medium"); got != "medium" { + t.Fatalf("explicit=%q", got) + } + if got := defaultReasoningEffort("gpt-4o", 1000, ""); got != "" { + t.Fatalf("classic=%q", got) + } +} diff --git a/apps/api/internal/processing/openai_test.go b/apps/api/internal/processing/openai_test.go index 72c6f73..39529b9 100644 --- a/apps/api/internal/processing/openai_test.go +++ b/apps/api/internal/processing/openai_test.go @@ -179,6 +179,52 @@ func TestOpenAIClient_doComplete_emptyContentWithReasoningJSON(t *testing.T) { } } +func TestOpenAIClient_doComplete_gpt56LunaUsesMaxCompletionTokens(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + if _, ok := req["temperature"]; ok { + t.Errorf("temperature must be omitted for gpt-5.6-luna") + } + if _, ok := req["max_tokens"]; ok { + t.Errorf("max_tokens must be omitted for gpt-5.6-luna") + } + gotMax, _ := req["max_completion_tokens"].(float64) + if int(gotMax) != MaxTokensEnhance { + t.Errorf("max_completion_tokens=%v want %d", gotMax, MaxTokensEnhance) + } + if req["reasoning_effort"] != "low" { + t.Errorf("reasoning_effort=%v want low", req["reasoning_effort"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "model": "gpt-5.6-luna", + "choices": []map[string]any{{ + "finish_reason": "stop", + "message": map[string]any{ + "role": "assistant", + "content": `{"name":"Acme Monitor","description":"
IPS panel for office use.
"}`, + }, + }}, + "usage": map[string]int{"prompt_tokens": 20, "completion_tokens": 40, "total_tokens": 60}, + }) + })) + defer srv.Close() + c := NewOpenAIClient("test-key", srv.URL, "gpt-5.6-luna", 0, 1) + c.HTTPClient = srv.Client() + comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{ + MaxTokens: MaxTokensEnhance, + Temperature: DefaultStructuredTemp, + ReasoningEffort: "low", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(comp.Text, "Acme Monitor") { + t.Fatalf("text=%q", comp.Text) + } +} + func TestOpenAIClient_doComplete_emptyContentLengthRetriesWithBudget(t *testing.T) { t.Parallel() calls := 0 diff --git a/apps/api/internal/processing/sanitize.go b/apps/api/internal/processing/sanitize.go index 9d1b404..c9747a6 100644 --- a/apps/api/internal/processing/sanitize.go +++ b/apps/api/internal/processing/sanitize.go @@ -11,7 +11,7 @@ import ( const maxPromptFieldRunes = 4000 // maxOutputFieldRunes bounds stored / polled model text (titles + multi-section -// formula HTML descriptions). Must stay well above MaxTokensEnhance (~16k tokens) +// formula HTML descriptions). Must stay well above MaxTokensEnhance (~24k tokens) // so SanitizeOutput does not chop JSON completion bodies or A1 HTML mid-string. const maxOutputFieldRunes = 120000 diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index 7add4b8..8bbb976 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -800,8 +800,9 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, logEnhancePrompt(in, catUID, catName, system, user) started := time.Now() comp, obj, err := CompleteJSON(ctx, e.Completer, system, user, CompleteOptions{ - MaxTokens: MaxTokensEnhance, - Temperature: DefaultStructuredTemp, + MaxTokens: MaxTokensEnhance, + Temperature: DefaultStructuredTemp, + ReasoningEffort: "low", }) elapsed := time.Since(started) if err != nil { @@ -891,8 +892,9 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, retryUser := user + descriptionFormulaRetrySuffix retryStarted := time.Now() comp2, obj2, err2 := CompleteJSON(ctx, e.Completer, system, retryUser, CompleteOptions{ - MaxTokens: MaxTokensEnhance, - Temperature: DefaultStructuredTemp, + MaxTokens: MaxTokensEnhance, + Temperature: DefaultStructuredTemp, + ReasoningEffort: "low", }) elapsed += time.Since(retryStarted) comp.PromptTokens += comp2.PromptTokens diff --git a/apps/web/src/lib/admin-ai-roles.ts b/apps/web/src/lib/admin-ai-roles.ts index d378cf8..83f3fb0 100644 --- a/apps/web/src/lib/admin-ai-roles.ts +++ b/apps/web/src/lib/admin-ai-roles.ts @@ -31,7 +31,7 @@ export const AI_ROLE_META: Record< processing: { label: "Processing", description: "Product pipeline chat/completions (titles, descriptions, enhance).", - modelPlaceholder: "gpt-4o-mini" + modelPlaceholder: "gpt-5.6-luna" }, vectorization: { label: "Vectorization", @@ -42,13 +42,13 @@ export const AI_ROLE_META: Record< label: "Docs / API", description: "Future docs/API assistant slot — /docs Ask stays rule-based and must never call this role.", - modelPlaceholder: "gpt-4o-mini" + modelPlaceholder: "gpt-5.6-luna" }, support: { label: "Support", description: "Ticket auto-reply AI fallback — configure provider/key/model here; enable delivery in Support knowledge → Auto-reply.", - modelPlaceholder: "gpt-4o-mini" + modelPlaceholder: "gpt-5.6-luna" } }; diff --git a/docs/local-llm-tuning.md b/docs/local-llm-tuning.md index 2046adc..6166ad9 100644 --- a/docs/local-llm-tuning.md +++ b/docs/local-llm-tuning.md @@ -16,27 +16,29 @@ Restart **both** `api` and `worker` after changing `OPENAI_*`. Worker logs shoul ## Product enhance model choice -Admin AI role for **product enhance** should prefer a **fast non-reasoning chat model** that emits `message.content` (JSON) under budget. +Admin AI role for **product enhance** should prefer a model that emits `message.content` (JSON) under budget. Recommended OpenAI: **`gpt-5.6-luna`** (high-volume / cost-efficient GPT-5.6 tier). -Reasoning / “code-fast” style models often spend the entire `max_tokens` budget in `reasoning_content`, then return `finish_reason=length` with empty or truncated JSON. Descrybe retries once at `MaxTokensEnhanceRetry` and may synthesize formula HTML as salvage — that is **not** live LLM formula compliance. +Reasoning / “code-fast” style models often spend the entire completion budget in `reasoning_content`, then return `finish_reason=length` with empty or truncated JSON. Descrybe retries once at `MaxTokensEnhanceRetry` and may synthesize formula HTML as salvage — that is **not** live LLM formula compliance. -If enhance logs show `finish_reason=length` with empty/truncated content even after the retry, switch Admin enhance to a non-reasoning model. +For GPT-5 / o-series Chat Completions, the client sends `max_completion_tokens` (not `max_tokens`), omits custom `temperature`, and sets `reasoning_effort` (`none` for probes, `low` for long formula-HTML enhance) so visible HTML is not eaten by deep reasoning. + +If enhance logs show `finish_reason=length` with empty/truncated content even after the retry, switch Admin enhance to a lower-reasoning or classic chat model (e.g. `gpt-4o-mini`). ## Hardening defaults (code) | Constant | Value | Where | |----------|------:|-------| -| `DefaultStructuredTemp` | `0.2` (capped ≤0.3) | `processing/llm_json.go`, `openai.go` | -| `MaxTokensEnhance` | `16384` | product title/description (reasoning + formula HTML JSON) | -| `MaxTokensEnhanceRetry` | `32768` | one-shot bump on `finish_reason=length` empty/truncated JSON | +| `DefaultStructuredTemp` | `0.2` (capped ≤0.3; omitted on GPT-5/o-series) | `processing/llm_json.go`, `openai.go` | +| `MaxTokensEnhance` | `24576` | product title + multi-section HTML description JSON | +| `MaxTokensEnhanceRetry` | `40960` | one-shot bump on `finish_reason=length` empty/truncated JSON | | `MaxTokensSEO` | `180` | meta title/description | | `MaxTokensCampaign` | `650` | email JSON | -| `MaxProductDescRunes` | `400` | user context | +| `MaxProductDescRunes` | `3500` | source description in enhance user context | | `MaxAttrKeys` / `MaxAttrValueRunes` | `10` / `60` | attrs in enhance prompt | | `MaxBrandInjectRunes` | `500` | brand kit block | | `MaxCampaignProducts` | `8` | campaign product list | -OpenAI-compatible chat completions here use a single `max_tokens` completion budget (no separate reasoning vs content split on typical gateways). Length-cap handling lives in `OpenAIClient.doComplete` / `CompleteWithOptions`. +Classic OpenAI-compatible chat completions use `max_tokens`. GPT-5 / o-series use `max_completion_tokens` (budget includes hidden reasoning tokens). Length-cap handling lives in `OpenAIClient.doComplete` / `CompleteWithOptions`. ## Prompt style @@ -49,7 +51,7 @@ OpenAI-compatible chat completions here use a single `max_tokens` completion bud `processing.CompleteJSON`: -1. Call Completer with `max_tokens` + low temperature +1. Call Completer with completion budget + low temperature (classic) / GPT-5.6 param mapping 2. `StripJSONFences` / isolate `{…}` 3. On parse fail → **one retry** with `INVALID. Reply with ONLY one JSON object…` 4. Call sites fall back to originals/templates instead of storing garbage prose as titles