2026-06-26 14:22:05 +00:00
|
|
|
package llm
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
const (
|
|
|
|
|
// chatCompletionsPath is the endpoint for chat completion requests.
|
|
|
|
|
chatCompletionsPath = "/chat/completions"
|
|
|
|
|
// chatTemplateEnableThinking is the chat-template argument that selects
|
|
|
|
|
// whether a reasoning model emits reasoning tokens.
|
|
|
|
|
chatTemplateEnableThinking = "enable_thinking"
|
|
|
|
|
// defaultTimeout limits requests when Config.Timeout is empty.
|
|
|
|
|
defaultTimeout = 5 * time.Minute
|
|
|
|
|
// sseDone is the sentinel that terminates a chat completion stream.
|
|
|
|
|
sseDone = "[DONE]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Config configures a Client.
|
2026-06-26 14:22:05 +00:00
|
|
|
type Config struct {
|
2026-09-20 12:49:42 +00:00
|
|
|
// Key is sent as a bearer token. An empty key disables authentication.
|
2026-06-26 14:22:05 +00:00
|
|
|
Key string `yaml:"key"`
|
2026-09-20 12:49:42 +00:00
|
|
|
// Provider is returned unchanged by Client.Provider. It is not sent to the
|
|
|
|
|
// inference endpoint.
|
|
|
|
|
Provider string `yaml:"provider"`
|
|
|
|
|
// Timeout limits each request, including reading a streaming response. It
|
|
|
|
|
// uses Go duration syntax and defaults to five minutes when empty.
|
2026-06-26 14:22:05 +00:00
|
|
|
Timeout string `yaml:"timeout"`
|
2026-09-20 12:49:42 +00:00
|
|
|
// URL is the endpoint root. Client appends /chat/completions or /models.
|
2026-06-26 14:22:05 +00:00
|
|
|
URL string `yaml:"url"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
// Validate reports whether Timeout is a Go duration, when set, and URL is
|
|
|
|
|
// non-empty. It does not contact the endpoint.
|
2026-06-26 14:22:05 +00:00
|
|
|
func (cfg Config) Validate() error {
|
|
|
|
|
if cfg.Timeout != "" {
|
|
|
|
|
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
|
|
|
|
return fmt.Errorf("invalid timeout: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if cfg.URL == "" {
|
|
|
|
|
return fmt.Errorf("missing URL")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
// Client is an OpenAI-compatible inference client.
|
2026-06-26 14:22:05 +00:00
|
|
|
type Client struct {
|
2026-09-20 12:49:42 +00:00
|
|
|
baseURL string
|
|
|
|
|
httpc *http.Client
|
|
|
|
|
key string
|
|
|
|
|
log *slog.Logger
|
|
|
|
|
provider string
|
|
|
|
|
timeout time.Duration
|
2026-06-26 14:22:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
// NewClient creates a client from cfg. If cfg.Timeout is empty, requests have a
|
|
|
|
|
// five-minute timeout. log must be non-nil.
|
|
|
|
|
func NewClient(cfg Config, log *slog.Logger) (*Client, error) {
|
2026-06-26 14:22:05 +00:00
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
|
|
|
}
|
2026-09-20 12:49:42 +00:00
|
|
|
if log == nil {
|
|
|
|
|
return nil, fmt.Errorf("missing logger")
|
|
|
|
|
}
|
2026-06-26 14:22:05 +00:00
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
timeout := defaultTimeout
|
2026-06-26 14:22:05 +00:00
|
|
|
if cfg.Timeout != "" {
|
|
|
|
|
d, err := time.ParseDuration(cfg.Timeout)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse timeout: %v", err)
|
|
|
|
|
}
|
|
|
|
|
timeout = d
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
return &Client{
|
|
|
|
|
baseURL: cfg.URL,
|
|
|
|
|
key: cfg.Key,
|
|
|
|
|
log: log,
|
|
|
|
|
provider: cfg.Provider,
|
|
|
|
|
httpc: &http.Client{},
|
|
|
|
|
timeout: timeout,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
2026-06-26 14:22:05 +00:00
|
|
|
|
2026-09-20 12:49:42 +00:00
|
|
|
// Provider returns the application-defined provider name supplied in Config.
|
|
|
|
|
func (c *Client) Provider() string {
|
|
|
|
|
return c.provider
|
2026-06-26 14:22:05 +00:00
|
|
|
}
|