105 lines
3.6 KiB
Go
105 lines
3.6 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
|
||
// ExtraBody is merged into every request JSON for this provider's models
|
||
// (e.g. GLM {"thinking":{"type":"disabled"}}). Per-model extras from
|
||
// models.yaml are passed the same way when the client is built per-model.
|
||
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
|
||
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,
|
||
extra: cfg.ExtraBody,
|
||
}
|
||
}
|
||
|
||
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
|
||
effort := req.ReasoningEffort
|
||
if effort == "off" {
|
||
// "off" is our neutral value; OpenAI-compat providers with a
|
||
// non-standard disable switch get it via ExtraBody instead.
|
||
effort = ""
|
||
}
|
||
resp, err := c.http.complete(ctx, openAIRequest{
|
||
Model: req.Model,
|
||
Messages: toOpenAIMessages(req.Messages),
|
||
MaxTokens: req.MaxTokens,
|
||
Temperature: req.Temperature,
|
||
Stream: false,
|
||
ReasoningEffort: effort,
|
||
ResponseFormat: jsonResponseFormat(req.JSONOnly),
|
||
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
|
||
}
|