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 }