package llm import ( "context" "log/slog" "net/http" "strings" ) // provider_openai.go is the generic adapter for every OpenAI-compatible cloud // provider (DeepSeek, GLM/Z.AI, Kimi, xAI, ru-aggregators). Vojo had a // per-vendor file each (provider_xai.go etc.), 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 (see Usage.ReasoningTokens in 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, } } // additiveReasoning applies the xAI additive-reasoning identity guard (research/21 // §1.9). xAI Chat Completions reports reasoning ADDITIVELY — total == prompt + // completion + reasoning — and dropping it undercounted Grok spend 30–44% in vojo, so // when that identity holds we surface reasoning as billed-on-top. But if the identity // is BROKEN (total is reported and ≠ the sum), reasoning is already folded INTO // completion_tokens (subset semantics) and adding it again would DOUBLE-BILL (the ~30–44% // overcount, now latent) — so we treat it as subset (0) and WARN. The WARN is a // wire-drift signal and a candidate for 00-provider-quirks (rule of two directions, // 10.07: a live grok-usage probe decides, we do not silently absorb). When total is not // reported (0) the identity is unverifiable, so we keep the known-correct xAI additive // default rather than blind the ledger. func additiveReasoning(ctx context.Context, log *slog.Logger, provider string, u openAIUsage) int { rt := u.CompletionTokensDetails.ReasoningTokens if rt <= 0 { return 0 } if u.TotalTokens == 0 || u.TotalTokens == u.PromptTokens+u.CompletionTokens+rt { return rt // identity holds, or total unreported → keep the additive default } if log != nil { log.WarnContext(ctx, "additive-reasoning identity broken; counting reasoning as subset (double-count guard) — candidate quirk, vendor-check per rule of two directions", "provider", provider, "prompt", u.PromptTokens, "completion", u.CompletionTokens, "reasoning", rt, "total", u.TotalTokens) } return 0 } 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 = additiveReasoning(ctx, c.http.log, c.http.name, resp.Usage) 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: normalizeOpenAIFinish(resp.FinishReason()), ProviderRequestID: resp.ID, }, nil } // normalizeOpenAIFinish maps a KNOWN non-standard OpenAI-compat finish_reason onto the // neutral vocabulary — the wire→neutral mapping that belongs at the adapter boundary // (like mapAnthropicStopReason), so the downstream classifier keeps its exact match and // stays vendor-free. Two known non-1:1 cases (research/21 §1.18б, pack-12 points 8b + the // GLM vendor-check): // // - GLM/z.ai `sensitive` is its content filter → neutral content_filter (docs.z.ai // chat-completion reference — stop | tool_calls | length | sensitive | // model_context_window_exceeded | network_error; vendor-checked 2026-07-24, rule of // two directions). Our editor IS GLM, so a filtered edit must read as a filter. // - Gemini via the OpenAI-compat layer emits a COMPOSITE `content_filter: // PROHIBITED_CONTENT` (D22.8а) — collapse the `content_filter:` composite onto // the neutral token so the exact matcher fires; exact matchers are dead on that wire. // // Only the content_filter composite is collapsed. EVERY other value — `network_error`, // `model_context_window_exceeded`, any other composite, any unknown — passes through RAW so // it stays FAIL-LOUD (it flows to the content/coverage gates, never a silently-mapped clean // stop, the litellm unknown→stop trap the strict finish=stop-only contract rejects). Spec // providers' values already map 1:1, so this is a no-op for them. func normalizeOpenAIFinish(finish string) string { if finish == "sensitive" || strings.HasPrefix(finish, FinishContentFilter+":") { return FinishContentFilter } return finish }