183 lines
8.1 KiB
Go
183 lines
8.1 KiB
Go
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) {
|
||
// 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
|
||
}
|