package model import ( "io" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // discardLogger returns a logger that drops every record but still carries the // adapter's module source, so an adapter built without an injected logger can // never emit an unsourced record. func discardLogger(source string) *slog.Logger { return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source) } // Anthropic extended-thinking protocol bounds (design/04 §3.2). const ( // anthropicMinThinkingBudget is the smallest budget_tokens the Messages API // accepts for thinking.type=enabled; anything below it is rejected with a // 400, so a lower non-zero budget is raised and reported rather than sent. anthropicMinThinkingBudget = 1024 // anthropicDefaultThinkingBudget is used when thinking is switched on without // an explicit budget. anthropicDefaultThinkingBudget = 4096 // anthropicThinkingBodyReserve is the body room kept on top of the thinking // budget: max_tokens is shared by thinking and the visible answer, so the // budget must never be allowed to consume it all (design/04 §3.2 red line: // "预算与正文不得互相吃光"). anthropicThinkingBodyReserve = 2048 ) // reasoningVendor identifies the vendor behind an OpenAI-compatible endpoint. // The wire protocol is the same for all of them, but the accepted // reasoning_effort values are not, so a value must be checked against the right // whitelist instead of being forwarded blindly. type reasoningVendor string const ( vendorOpenAI reasoningVendor = "openai" vendorDeepSeek reasoningVendor = "deepseek" vendorQwen reasoningVendor = "qwen" vendorUnknown reasoningVendor = "unknown" ) // reasoningEffortLadder is the canonical ascending order of reasoning_effort // tiers. It is used to round an unsupported value down to the nearest tier a // vendor accepts. var reasoningEffortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} // reasoningEffortAllowed is the per-vendor whitelist. A vendor absent from this // table has no reasoning_effort support at all, which is reported rather than // guessed. var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{ vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"), vendorDeepSeek: tierSet("low", "high", "max"), } // tierSet builds a membership set from the given tiers. func tierSet(tiers ...string) map[string]bool { set := make(map[string]bool, len(tiers)) for _, t := range tiers { set[t] = true } return set } // reasoningVendorFor classifies the vendor of an OpenAI-compatible request from // its model name, falling back to the base URL when the name does not identify // one. An unrecognized pair yields vendorUnknown so the caller reports "not // supported" instead of assuming OpenAI semantics. func reasoningVendorFor(model, baseURL string) reasoningVendor { m := strings.ToLower(strings.TrimSpace(model)) switch { case strings.Contains(m, "deepseek"): return vendorDeepSeek case strings.Contains(m, "qwen"): return vendorQwen case strings.Contains(m, "gpt-"), strings.Contains(m, "chatgpt"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"): return vendorOpenAI } u := strings.ToLower(strings.TrimSpace(baseURL)) switch { case strings.Contains(u, "deepseek"): return vendorDeepSeek case strings.Contains(u, "dashscope"), strings.Contains(u, "aliyuncs"): return vendorQwen case strings.Contains(u, "openai"): return vendorOpenAI } return vendorUnknown } // resolveReasoningEffort validates effort against the vendor's whitelist and // returns the value to send. An unsupported tier is rounded down to the nearest // tier the vendor accepts; a value the vendor does not accept at all — or a // vendor with no whitelist — yields "", meaning the field is dropped. Every // decision is logged (provider, field, value), never silently applied. func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string { effort = strings.TrimSpace(effort) if effort == "" { return "" } allowed, ok := reasoningEffortAllowed[vendor] if !ok { log.Warn("reasoning_effort ignored: provider does not support it", "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) return "" } if allowed[effort] { return effort } downgraded, ok := downgradeReasoningEffort(allowed, effort) if !ok { log.Warn("reasoning_effort dropped: unrecognized value", "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) return "" } log.Warn("reasoning_effort downgraded: value not accepted by provider", "provider", string(vendor), "field", "reasoning_effort", "value", effort, "sent", downgraded, "model", model) return downgraded } // downgradeReasoningEffort rounds an unsupported tier down to the nearest tier // the vendor accepts using the canonical ladder order; a tier below the vendor's // minimum maps to that minimum. It reports false when effort is not on the // ladder at all, so the caller drops the field instead of inventing a value. func downgradeReasoningEffort(allowed map[string]bool, effort string) (string, bool) { start := -1 for i, tier := range reasoningEffortLadder { if tier == effort { start = i break } } if start < 0 { return "", false } for i := start; i >= 0; i-- { if allowed[reasoningEffortLadder[i]] { return reasoningEffortLadder[i], true } } for _, tier := range reasoningEffortLadder { if allowed[tier] { return tier, true } } return "", false } // anthropicThinkingFor resolves the extended-thinking directive together with the // max_tokens it requires. It returns the (possibly raised) max_tokens and the // wire block; a nil block means "do not send thinking" (no directive, or an // explicit off). Because max_tokens is shared by thinking and the visible answer, // the returned budget always leaves anthropicThinkingBodyReserve for the body, // and a request whose budget would eat the answer raises max_tokens instead — the // adjustment is logged so it is never a silent rewrite. func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) { if th == nil || (th.Enable != nil && !*th.Enable) { return maxTokens, nil } budget := th.BudgetTokens if budget == 0 { budget = anthropicDefaultThinkingBudget } if budget < anthropicMinThinkingBudget { log.Warn("thinking budget raised to the protocol minimum", "provider", "anthropic", "field", "thinking.budget_tokens", "budget_tokens", th.BudgetTokens, "sent", anthropicMinThinkingBudget) budget = anthropicMinThinkingBudget } if need := budget + anthropicThinkingBodyReserve; maxTokens < need { log.Warn("max_tokens raised to leave room for thinking plus the answer", "provider", "anthropic", "budget_tokens", budget, "max_tokens_requested", maxTokens, "max_tokens_sent", need, "body_reserve", anthropicThinkingBodyReserve) maxTokens = need } return maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget} } // reportUnmappedThinking logs why a Thinking directive cannot be carried by an // OpenAI-compatible request, naming the provider and the field that was ignored. // Qwen3 does have enable_thinking, but the protocol has no portable field for // it (vLLM wants chat_template_kwargs.enable_thinking, DashScope's compatible // mode wants a top-level enable_thinking), and sending the wrong shape can 400 // the request — so this version reports it instead of guessing. func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) { if th == nil { return } if th.Enable != nil { switch vendor { case vendorQwen: log.Warn("enable_thinking not forwarded: the OpenAI-compatible protocol has no portable field for it", "provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking", "enable", *th.Enable, "model", model) case vendorDeepSeek: log.Warn("deepseek thinking switch not mapped in this version; only reasoning_effort is forwarded", "provider", "deepseek", "field", "thinking.type", "enable", *th.Enable, "model", model) default: log.Warn("thinking switch ignored: provider has no boolean thinking switch", "provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model) } return } if th.BudgetTokens > 0 { log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget", "provider", string(vendor), "field", "thinking.budget_tokens", "budget_tokens", th.BudgetTokens, "model", model) } }