Files
majordomo/provider/openai/openai.go
T
steveandClaude Opus 4.8 2bfffff47a
CI / Tidy (pull_request) Successful in 9m29s
CI / Build & Test (pull_request) Successful in 10m23s
Gadfly review (reusable) / review (pull_request) Successful in 18m38s
Adversarial Review (Gadfly) / review (pull_request) Successful in 18m38s
feat: kimi (Moonshot AI) built-in provider (ADR-0026)
Add a first-class `kimi` provider and `kimi://` DSN scheme for Moonshot AI's
OpenAI-compatible Chat Completions API. Both reuse provider/openai (no new
client, mirroring llama-swap's chat path). Default endpoint is the
international host; the China endpoint is reachable via a kimi:// LLM_* DSN.

- Credential is KIMI_API_KEY, read through the registry's injected envLookup
  so it stays hermetically testable. WithAPIKey is passed unconditionally so
  an unset KIMI_API_KEY can never fall through to the openai client's
  OPENAI_API_KEY default.
- New openai.WithAPIKeyName option customizes the missing-key error hint
  (default OPENAI_API_KEY); kimi names KIMI_API_KEY.
- Hermetic tests: built-in base URL + bearer, missing-key hint names
  KIMI_API_KEY with no OPENAI fallthrough and no network hit, kimi:// scheme
  round-trips against the China host.
- Docs in sync: README built-in table + DSN scheme list + support matrix,
  .env.example, env.go DSN doc, ADR-0026 (+ index, backfilling 0024/0025),
  progress.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 02:59:30 -04:00

144 lines
5.1 KiB
Go

// Package openai implements llm.Provider for the OpenAI Chat Completions
// API and, via WithBaseURL/WithName, any OpenAI-compatible endpoint
// (vLLM, Groq, Together, LM Studio, Ollama's /v1 shim, ...).
//
// Targeted API surface (verified against developers.openai.com, June 2026):
// POST {base}/chat/completions with
// - messages: plain-string content for text-only turns, part arrays with
// base64 data-URL image_url entries for multimodal turns, assistant
// tool_calls history, and {"role":"tool","tool_call_id",...} results;
// - tools as {"type":"function","function":{...}} with tool_choice
// "auto"/"none"/"required" or a named-function object;
// - response_format {"type":"json_schema",...} structured output;
// - max_completion_tokens (or legacy max_tokens via WithLegacyMaxTokens
// for compat servers), temperature, top_p, stop, reasoning_effort;
// - data-only SSE streaming with stream_options.include_usage, the
// "data: [DONE]" sentinel, and tool-call deltas accumulated by index.
//
// Newer response fields (refusal, annotations, usage *_details, delta
// obfuscation) are tolerated and ignored so both api.openai.com and older
// compat servers decode cleanly.
package openai
import (
"net/http"
"os"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
const defaultBaseURL = "https://api.openai.com/v1"
// Provider is an llm.Provider backed by an OpenAI Chat Completions endpoint.
type Provider struct {
name string
apiKey string
apiKeyName string
baseURL string
client *http.Client
caps llm.Capabilities
legacyMaxTokens bool
}
// Option configures the provider at construction.
type Option func(*Provider)
// WithAPIKey sets the API key. When absent, New reads OPENAI_API_KEY from
// the environment at construction time.
func WithAPIKey(key string) Option {
return func(p *Provider) { p.apiKey = key }
}
// WithBaseURL points the client at a different endpoint (compat servers).
// The path "/chat/completions" is appended; a trailing slash is trimmed.
func WithBaseURL(u string) Option {
return func(p *Provider) { p.baseURL = u }
}
// WithHTTPClient substitutes the HTTP client (timeouts, proxies, tests).
func WithHTTPClient(c *http.Client) Option {
return func(p *Provider) {
if c != nil {
p.client = c
}
}
}
// WithAPIKeyName sets the environment-variable name shown in the missing-key
// error (default "OPENAI_API_KEY"). Why: the same client serves compat
// endpoints keyed by other env vars (e.g. KIMI_API_KEY), and the error should
// name the one the operator actually needs to set.
func WithAPIKeyName(name string) Option {
return func(p *Provider) { p.apiKeyName = name }
}
// WithName overrides the registry name ("openai" by default). Why: the same
// client serves many OpenAI-compatible endpoints, and each needs a distinct
// name in "provider/model" specs and error reporting.
func WithName(name string) Option {
return func(p *Provider) { p.name = name }
}
// WithDefaultCapabilities replaces the provider-default capabilities.
// Per-model overrides via llm.WithCapabilities still take precedence.
func WithDefaultCapabilities(caps llm.Capabilities) Option {
return func(p *Provider) { p.caps = caps }
}
// WithLegacyMaxTokens sends Request.MaxTokens as "max_tokens" instead of
// "max_completion_tokens". Why: OpenAI deprecated max_tokens, but many
// third-party compat servers still only honor the legacy field.
func WithLegacyMaxTokens() Option {
return func(p *Provider) { p.legacyMaxTokens = true }
}
// defaultCapabilities reflects OpenAI's current vision-capable chat models.
// Why these limits: the published per-request caps (1500 images, 512 MB)
// are far beyond what compat servers accept; 100 images / 20 MB each is a
// conservative envelope, and the MIME list is the documented set (PNG,
// JPEG, WEBP, non-animated GIF).
func defaultCapabilities() llm.Capabilities {
return llm.Capabilities{
SupportsTools: true,
SupportsStructured: true,
SupportsStreaming: true,
MaxImagesPerReq: 100,
MaxImageBytes: 20 << 20,
AllowedImageMIME: []string{"image/jpeg", "image/png", "image/webp", "image/gif"},
}
}
// New creates a Provider. It never fails: a missing API key surfaces as a
// 401-style *llm.APIError at request time, not at construction.
func New(opts ...Option) *Provider {
p := &Provider{
name: "openai",
apiKey: os.Getenv("OPENAI_API_KEY"),
apiKeyName: "OPENAI_API_KEY",
baseURL: defaultBaseURL,
client: http.DefaultClient,
caps: defaultCapabilities(),
}
for _, opt := range opts {
opt(p)
}
p.baseURL = strings.TrimRight(p.baseURL, "/")
return p
}
// Name implements llm.Provider.
func (p *Provider) Name() string { return p.name }
// Model implements llm.Provider. The id is passed through verbatim — no
// catalog validation; unknown models fail at request time with the
// backend's own error.
func (p *Provider) Model(id string, opts ...llm.ModelOption) (llm.Model, error) {
cfg := llm.ApplyModelOptions(opts)
caps := p.caps
if cfg.Capabilities != nil {
caps = *cfg.Capabilities
}
return &model{p: p, id: id, caps: caps}, nil
}