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%). Read from // completion_tokens_details.reasoning_tokens. ReasoningAdditive ReasoningSemantics = "additive" // ReasoningAdditiveTotal: thinking is billed as output but reported ONLY in // total_tokens — Gemini via the OpenAI-compat layer omits it from // completion_tokens AND has no reasoning_tokens field (live-probe 2026-07-10: // completion=2, prompt=22, total=847 → 823 hidden thinking). Derived as // total − prompt − completion so the mandatory-thinking spend (D6.3) is billed // as output instead of blinding the ceiling. ReasoningAdditiveTotal ReasoningSemantics = "additive_total" ) // 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, } switch c.reasoning { case ReasoningAdditive: usage.ReasoningTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens case ReasoningAdditiveTotal: // Gemini: thinking is only in total_tokens (not completion, no reasoning field). // Guard >0 so a spec provider whose total == prompt+completion derives 0, not a // negative (never inflate the ceiling either). if extra := resp.Usage.TotalTokens - resp.Usage.PromptTokens - resp.Usage.CompletionTokens; extra > 0 { usage.ReasoningTokens = extra } } model := resp.Model if model == "" { model = req.Model } return &LLMResponse{ Text: resp.Text(), Usage: usage, Model: model, FinishReason: resp.FinishReason(), ProviderRequestID: resp.ID, }, nil }