textmachine/backend/internal/llm/provider_openai.go

183 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 3044%). 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 3044% 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 ~3044%
// 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) {
// The system-run join is resolved BEFORE the request is built, and its refusal is returned
// unretried: an un-joinable message list is a request-shape error, and retrying it would only
// buy the same refusal three times.
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
if err != nil {
return nil, err
}
resp, err := c.http.complete(ctx, openAIRequest{
model: req.Model,
messages: msgs,
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:<detail>` 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
}