mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +02:00
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)
Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.
Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.
Quality changes that came with the move:
- The prompt now states the frame explicitly ("you write FOR THE USER"). The
old pass ran inside the agent's session and inherited the runtime brief's
identity, which drifted suggestions toward agent-operations actions.
- Previously-offered labels are replayed as ALREADY SUGGESTED. The old
architecture had the opposite effect: on providers that append on resume,
each pass saw its predecessor's JSON and anchored on it.
- A failed generation broadcasts failed=true. Before, a timeout delivered an
empty array — indistinguishable from "nothing worth suggesting", so every
slow pass read as a quality problem.
- The in-band footer is still stripped from replies but its actions are now
discarded, so a pre-upgrade session is not pinned to the retired
suggestions with the replacing pass suppressed.
The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.
Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)
Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.
The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.
agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions review findings (MUL-5573)
Four defects from review of the server-side generation change.
1. Automatic failures were reported as refresh failures. The generator
broadcast failed=true on any LLM error, but the client turns every
failed=true into a "couldn't refresh" toast — so an automatic timeout
popped a toast for an action the user never took. This also contradicted
ChatQuickActionsPayload.Failed, which documents false for the automatic
pass. The caller now passes its origin; only an explicit refresh reports.
2. Generation context was not bound to the target turn. The pass re-read the
session's newest messages while always writing to the task it was handed,
so a turn landing between the completion callback and the detached read
supplied the context for a reply it did not belong to. Worse, a user
typing a follow-up in the second after a reply left the window ending on
a user row, which the old code treated as "nothing to build on" — that
turn silently never got pills. The window is now anchored on the target
assistant message and queried strictly before it.
3. No concurrency or idempotency bound on generation. Refresh stopped
creating a task, so the busy check could not see a pass already running:
two refreshes both returned 202, spent two upstream calls, and raced to
write one row. Nothing bounded generation process-wide either. Adds a
per-session in-flight guard (refresh now 409s on a duplicate) and a
process-wide ceiling; a shed pass still resolves the client placeholder
so no skeleton hangs on work that never started.
4. A new daemon could not safely talk to an older server. The refresh task
discriminator was deleted, so a regenerate task from such a server fell
through to the ordinary chat path: no user message, but the agent would
answer anyway and the server would persist it as a real reply. The field
is restored as a refusal marker only — the task completes empty, which is
the shape the retired pass produced and which that server writes no row
for. Not a restored execution path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
253 lines
9.9 KiB
Go
253 lines
9.9 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/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(¶ms)
|
|
|
|
// 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(¶ms)
|
|
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.
|
|
//
|
|
// temperature and maxTokens apply only when positive; zero leaves the upstream
|
|
// default in place. Model empty -> the configured default.
|
|
func (c *Client) GenerateJSON(ctx context.Context, model, systemPrompt, userPrompt string, temperature float64, maxTokens 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{},
|
|
},
|
|
}
|
|
if temperature > 0 {
|
|
params.Temperature = openai.Float(temperature)
|
|
}
|
|
if maxTokens > 0 {
|
|
params.MaxTokens = openai.Int(maxTokens)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|