mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* feat(server): remove generic LLM passthrough endpoints (MUL-4309) Remove the OpenAI-compatible passthrough HTTP handlers LLMChatCompletions / LLMChatCompletionsStream and their two routes (/api/llm/v1/chat/completions[/stream]) plus their tests. Exposing a generic LLM proxy backed by the deployment key let any logged-in user run arbitrary completions on our dime. pkg/llm and the MULTICA_LLM_* config are kept unchanged as the server-internal LLM entry point, so chat title generation (maybeGenerateChatTitleAsync -> h.LLM.GenerateText) continues to work untouched. Updated the handler.go and .env.example comments to reflect internal-only usage. Co-authored-by: multica-agent <github@multica.ai> * docs(server): fix stale comments referencing removed LLM passthrough handlers (MUL-4309) Address GPT-Boy review nits: three doc comments still described the deleted OpenAI-compatible HTTP proxy handlers / 503 behavior. Update pkg/llm/client.go (package doc + ErrNotConfigured) and the Handler.LLM field comment to describe the internal-only usage. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
206 lines
8.1 KiB
Go
206 lines
8.1 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-4o-mini"
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|