106 lines
3.8 KiB
Go
106 lines
3.8 KiB
Go
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 30–44%).
|
||
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
|
||
}
|