textmachine/backend/internal/llm/provider_openai.go

106 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package llm
import (
"context"
"log/slog"
"net/http"
)
// provider_openai.go is the generic adapter for every OpenAI-compatible cloud
// provider (DeepSeek, GLM/Z.AI, Kimi, xAI, ru-агрегаторы). Vojo had a
// per-vendor file each (provider_xai.go и т.п.), but their bodies were
// identical shells over the shared transport; TextMachine's differences are
// data (base URL, key, usage semantics, extra body), so one adapter
// parameterized from models.yaml replaces the family.
// ReasoningSemantics tells the adapter how a provider accounts thinking
// tokens (см. Usage.ReasoningTokens в llm.go).
type ReasoningSemantics string
const (
// ReasoningSubset: thinking is counted INSIDE completion_tokens (OpenAI
// spec, ollama, DeepSeek). ReasoningTokens stays 0 to avoid double-billing.
ReasoningSubset ReasoningSemantics = "subset"
// ReasoningAdditive: thinking is reported separately and billed ON TOP of
// completion_tokens (xAI — verified in vojo against cost_in_usd_ticks;
// dropping it undercounted Grok spend by 3044%).
ReasoningAdditive ReasoningSemantics = "additive"
)
// OpenAICompatConfig configures one provider instance.
type OpenAICompatConfig struct {
Name string // provider label for logs/telemetry
BaseURL string // ".../v1"-style base; transport appends /chat/completions
APIKey string
Profile RetryProfile
Headers map[string]string // static extra headers, may be nil
Reasoning ReasoningSemantics
// Cap is the resolved per-model wire shape (capability.go). Zero value =
// OpenAI-compat baseline (max_tokens + send temperature + off-by-omission).
Cap Capability
// ExtraBody is merged into every request JSON for this provider's models.
// Per-model extras from models.yaml are passed the same way when the client
// is built per-model. Provider-specific reasoning switches now live in Cap
// (capabilities.reasoning), not here; ExtraBody remains for other tuning.
ExtraBody map[string]any
// HTTPClient overrides the default client (nil = default). The local
// provider uses this for the no-proxy transport.
HTTPClient *http.Client
}
type openAICompatClient struct {
http *openAIClient
reasoning ReasoningSemantics
cap Capability
extra map[string]any
}
// NewOpenAICompatClient builds an adapter for one OpenAI-compatible provider.
func NewOpenAICompatClient(cfg OpenAICompatConfig, logger *slog.Logger) LLMClient {
sem := cfg.Reasoning
if sem == "" {
sem = ReasoningSubset // the spec default; additive is the xAI exception
}
return &openAICompatClient{
http: newOpenAIClient(cfg.Name, cfg.BaseURL, cfg.APIKey, cfg.Profile, cfg.Headers, cfg.HTTPClient, logger),
reasoning: sem,
cap: cfg.Cap,
extra: cfg.ExtraBody,
}
}
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
resp, err := c.http.complete(ctx, openAIRequest{
model: req.Model,
messages: toOpenAIMessages(req.Messages),
maxTokens: req.MaxTokens,
temperature: req.Temperature,
stream: false,
reasoningEffort: req.ReasoningEffort, // neutral; cap maps it to the wire
responseFormat: jsonResponseFormat(req.JSONOnly),
cap: c.cap,
extra: c.extra,
})
if err != nil {
return nil, err
}
usage := Usage{
PromptTokens: resp.Usage.PromptTokens,
CachedTokens: resp.Usage.cacheRead(),
CompletionTokens: resp.Usage.CompletionTokens,
}
if c.reasoning == ReasoningAdditive {
usage.ReasoningTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens
}
model := resp.Model
if model == "" {
model = req.Model
}
return &LLMResponse{
Text: resp.Text(),
Usage: usage,
Model: model,
FinishReason: resp.FinishReason(),
ProviderRequestID: resp.ID,
}, nil
}