Files
multica/server/pkg/llm/client.go
Multica Eve 33a2743f93 fix(llm): make quick actions GPT-5.6 compatible (MUL-5573) (#6243)
* fix(llm): use max completion token limits

Co-authored-by: multica-agent <github@multica.ai>

* fix(llm): harden GPT-5.6 JSON generation

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 17:44:37 +08:00

322 lines
13 KiB
Go

// Package llm is a thin, reusable wrapper around the official OpenAI Go SDK
// (github.com/openai/openai-go). It exists so the rest of the server has a
// single, well-typed entry point for "just call an LLM" needs that do NOT
// require the full agent runtime — e.g. generating a chat title or drafting a
// quick-create issue (MUL-4238).
//
// The wrapper is intentionally small:
//
// - It owns the SDK client construction (base URL + API key + retry/timeout
// defaults) so callers never touch option.RequestOption directly.
// - It exposes both the raw Chat Completions surface (Chat / ChatStream)
// and a convenience GenerateText helper, used by server-internal callers
// for simple one-shot completions (e.g. chat title generation).
// - The default model is configurable; when a request omits the model we
// fall back to it, and when it too is empty we fall back to a sane
// built-in default so a misconfigured deployment still returns a clear
// upstream error rather than a 400 from our own layer.
//
// Base URL and API key are configurable so the same layer can target OpenAI,
// an OpenAI-compatible gateway, or a self-hosted model server.
package llm
import (
"context"
"errors"
"net/http"
"strings"
"time"
openai "github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/packages/param"
"github.com/openai/openai-go/v3/packages/ssestream"
"github.com/openai/openai-go/v3/shared"
)
// FallbackModel is the last-resort model used when neither the request nor the
// configured default supplies one. It is deliberately a small, inexpensive
// model since this layer backs lightweight utility calls.
const FallbackModel = "gpt-5.6-luna"
// defaultTimeout bounds the full request lifecycle (including SDK retries) when
// the caller's context has no deadline of its own. Streaming requests are not
// subject to this because the handler owns the connection lifetime.
const defaultRequestTimeout = 60 * time.Second
// ErrNotConfigured is returned by Chat/ChatStream/GenerateText when the client
// was constructed without any credentials or base URL. Internal callers should
// treat this as a disabled-LLM signal and fall back gracefully (e.g. chat
// title generation keeps the original title) so a misconfigured self-hosted
// deployment never dials OpenAI with no key.
var ErrNotConfigured = errors.New("llm: no API key or base URL configured")
// Config holds the tunables for the LLM layer. All fields are optional; an
// empty Config yields a disabled client (see Client.Enabled).
type Config struct {
// APIKey authenticates against the upstream. Maps to MULTICA_LLM_API_KEY.
APIKey string
// BaseURL points at OpenAI or any OpenAI-compatible gateway. When empty the
// SDK's default (https://api.openai.com/v1) is used. Maps to
// MULTICA_LLM_BASE_URL.
BaseURL string
// DefaultModel is used when a request omits the model. Maps to
// MULTICA_LLM_DEFAULT_MODEL. When empty, FallbackModel is used.
DefaultModel string
// MaxRetries overrides the SDK default (2). A negative value is treated as
// zero (no retries).
MaxRetries int
// HTTPClient, when set, replaces the SDK's default transport. Primarily a
// test seam.
HTTPClient option.HTTPClient
}
// Client is a configured, reusable LLM caller. It is safe for concurrent use;
// the underlying SDK client holds no per-request state.
type Client struct {
sdk openai.Client
defaultModel string
enabled bool
}
// New builds a Client from cfg. It never returns an error: an unconfigured
// Config produces a disabled client whose calls return ErrNotConfigured, which
// keeps wiring in main/router simple (no boot-time failure when the LLM layer
// is simply not set up on a given deployment).
func New(cfg Config) *Client {
opts := make([]option.RequestOption, 0, 4)
if key := strings.TrimSpace(cfg.APIKey); key != "" {
opts = append(opts, option.WithAPIKey(key))
}
if base := strings.TrimSpace(cfg.BaseURL); base != "" {
opts = append(opts, option.WithBaseURL(base))
}
if cfg.MaxRetries != 0 {
retries := cfg.MaxRetries
if retries < 0 {
retries = 0
}
opts = append(opts, option.WithMaxRetries(retries))
}
if cfg.HTTPClient != nil {
opts = append(opts, option.WithHTTPClient(cfg.HTTPClient))
}
defaultModel := strings.TrimSpace(cfg.DefaultModel)
if defaultModel == "" {
defaultModel = FallbackModel
}
return &Client{
sdk: openai.NewClient(opts...),
defaultModel: defaultModel,
// A deployment is "configured" if it gave us either a key or a base
// URL. A bare base URL (no key) is valid for keyless local gateways.
enabled: strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.BaseURL) != "",
}
}
// Enabled reports whether the client was given any credentials or base URL.
// Handlers use this to short-circuit with a 503 before doing any work.
func (c *Client) Enabled() bool { return c != nil && c.enabled }
// DefaultModel returns the effective default model (never empty).
func (c *Client) DefaultModel() string { return c.defaultModel }
// applyDefaultModel fills in the default model when the caller left it blank.
func (c *Client) applyDefaultModel(params *openai.ChatCompletionNewParams) {
if strings.TrimSpace(string(params.Model)) == "" {
params.Model = shared.ChatModel(c.defaultModel)
}
}
// Chat performs a non-streaming chat completion. The params are passed through
// to the SDK verbatim (so tools, response_format, temperature, etc. are all
// honored); only the model default is applied. The returned *ChatCompletion
// exposes RawJSON() for byte-exact OpenAI-compatible responses.
func (c *Client) Chat(ctx context.Context, params openai.ChatCompletionNewParams) (*openai.ChatCompletion, error) {
if !c.Enabled() {
return nil, ErrNotConfigured
}
c.applyDefaultModel(&params)
// Give the request a bounded lifetime when the caller supplied none, so a
// hung upstream cannot pin a goroutine indefinitely.
ctx, cancel := withDefaultTimeout(ctx)
defer cancel()
return c.sdk.Chat.Completions.New(ctx, params)
}
// ChatStream performs a streaming chat completion, returning the SDK stream so
// the caller can relay chunks (each chunk exposes RawJSON() for byte-exact
// OpenAI-compatible SSE). The caller MUST call Close on the returned stream.
//
// Unlike Chat, no default timeout is imposed: the stream's lifetime is owned by
// the caller (typically an HTTP handler bound to the client connection).
func (c *Client) ChatStream(ctx context.Context, params openai.ChatCompletionNewParams) (*ssestream.Stream[openai.ChatCompletionChunk], error) {
if !c.Enabled() {
return nil, ErrNotConfigured
}
c.applyDefaultModel(&params)
return c.sdk.Chat.Completions.NewStreaming(ctx, params), nil
}
// GenerateText is a convenience for simple internal one-shot completions (chat
// titles, quick-create drafts, ...). It sends an optional system prompt plus a
// single user prompt and returns the assistant's text content. Model empty ->
// the configured default.
func (c *Client) GenerateText(ctx context.Context, model, systemPrompt, userPrompt string) (string, error) {
if !c.Enabled() {
return "", ErrNotConfigured
}
messages := make([]openai.ChatCompletionMessageParamUnion, 0, 2)
if strings.TrimSpace(systemPrompt) != "" {
messages = append(messages, openai.SystemMessage(systemPrompt))
}
messages = append(messages, openai.UserMessage(userPrompt))
params := openai.ChatCompletionNewParams{
Messages: messages,
Model: shared.ChatModel(strings.TrimSpace(model)),
}
completion, err := c.Chat(ctx, params)
if err != nil {
return "", err
}
if len(completion.Choices) == 0 {
return "", errors.New("llm: upstream returned no choices")
}
return completion.Choices[0].Message.Content, nil
}
// GenerateJSON is GenerateText's structured sibling, for internal callers whose
// reply has to be machine-readable (quick-action suggestions, ...). It requests
// response_format=json_object and returns the assistant's raw text unparsed.
//
// JSON-object mode only guarantees the reply is syntactically valid JSON, never
// that its shape matches what the prompt asked for, so the caller still owns
// parsing and validation. One upstream constraint the caller must honor: the
// word "JSON" has to appear somewhere in the prompt, or OpenAI-compatible
// endpoints reject the request outright.
//
// This helper is for small, latency-sensitive utility work. For the GPT-5.6
// family it explicitly disables reasoning and leaves sampling controls at the
// model default. That keeps maxCompletionTokens available to the visible JSON
// instead of spending it on reasoning, and avoids sampling parameters that
// those models may reject. Other models keep the caller's temperature so a
// configurable deployment does not change behavior. temperature and
// maxCompletionTokens apply only when positive; zero leaves the corresponding
// upstream default in place. Model empty -> the configured default.
func (c *Client) GenerateJSON(ctx context.Context, model, systemPrompt, userPrompt string, temperature float64, maxCompletionTokens int64) (string, error) {
if !c.Enabled() {
return "", ErrNotConfigured
}
messages := make([]openai.ChatCompletionMessageParamUnion, 0, 2)
if strings.TrimSpace(systemPrompt) != "" {
messages = append(messages, openai.SystemMessage(systemPrompt))
}
messages = append(messages, openai.UserMessage(userPrompt))
params := openai.ChatCompletionNewParams{
Messages: messages,
Model: shared.ChatModel(strings.TrimSpace(model)),
ResponseFormat: openai.ChatCompletionNewParamsResponseFormatUnion{
OfJSONObject: &shared.ResponseFormatJSONObjectParam{},
},
}
effectiveModel := strings.TrimSpace(model)
if effectiveModel == "" {
effectiveModel = c.defaultModel
}
if isGPT56Family(effectiveModel) {
// GPT-5.6 defaults to medium reasoning. This path generates a tiny JSON
// object under a strict wall-clock budget, so reasoning would add latency
// and consume the completion-token limit without improving the contract.
params.ReasoningEffort = shared.ReasoningEffortNone
} else if temperature > 0 {
params.Temperature = openai.Float(temperature)
}
if maxCompletionTokens > 0 {
// max_tokens is deprecated and rejected by current reasoning models,
// including the GPT-5.6 family. Prefer the replacement field for every
// upstream; a narrow compatibility retry below covers older gateways
// that have not implemented it yet.
params.MaxCompletionTokens = openai.Int(maxCompletionTokens)
}
// The preferred request and its optional compatibility retry share one
// deadline, so a legacy gateway cannot double the caller's time budget.
ctx, cancel := withDefaultTimeout(ctx)
defer cancel()
// Some older OpenAI-compatible gateways have not implemented one or both
// modern fields. Negotiate only when the upstream explicitly identifies an
// unsupported parameter: validation fails before generation, and each field
// can be removed or replaced at most once under the shared deadline.
var completion *openai.ChatCompletion
for compatibilityRetries := 0; ; compatibilityRetries++ {
var err error
completion, err = c.Chat(ctx, params)
if err == nil {
break
}
if compatibilityRetries >= 2 {
return "", err
}
switch {
case params.MaxCompletionTokens.Valid() && isUnsupportedParameter(err, "max_completion_tokens"):
params.MaxCompletionTokens = param.Opt[int64]{}
params.MaxTokens = openai.Int(maxCompletionTokens)
case params.ReasoningEffort != "" && isUnsupportedParameter(err, "reasoning_effort"):
params.ReasoningEffort = ""
default:
return "", err
}
}
if len(completion.Choices) == 0 {
return "", errors.New("llm: upstream returned no choices")
}
choice := completion.Choices[0]
if choice.FinishReason == "length" {
return "", errors.New("llm: upstream reached the max completion token limit before producing complete JSON")
}
if strings.TrimSpace(choice.Message.Content) == "" {
return "", errors.New("llm: upstream returned empty JSON content")
}
return choice.Message.Content, nil
}
func isUnsupportedParameter(err error, parameter string) bool {
var apiErr *openai.Error
if !errors.As(err, &apiErr) ||
apiErr.StatusCode != http.StatusBadRequest ||
apiErr.Param != parameter {
return false
}
return apiErr.Code == "unsupported_parameter" ||
(apiErr.Code == "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(apiErr.Message)), "unsupported parameter"))
}
func isGPT56Family(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
return model == "gpt-5.6" || strings.HasPrefix(model, "gpt-5.6-")
}
// withDefaultTimeout returns ctx unchanged (with a no-op cancel) when it already
// has a deadline, otherwise a child context bounded by defaultRequestTimeout.
func withDefaultTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
return context.WithTimeout(ctx, defaultRequestTimeout)
}
// compile-time assertion that option.HTTPClient is satisfied by *http.Client so
// callers can pass a plain *http.Client as the test seam.
var _ option.HTTPClient = (*http.Client)(nil)