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) } // Reasoning wire values shared by the OpenAI-compatible and Anthropic adapters // (design/04 §3.2, checked against the provider documents on 2026-10-03). const ( // thinkingTypeEnabled and thinkingTypeDisabled are the values of the // {"thinking":{"type":...}} switch. DeepSeek's default is enabled, so omitting // the field leaves thinking ON — "do not intervene" is not a way to switch it // off; only an explicit disabled (or reasoning_effort "none") is. thinkingTypeEnabled = "enabled" thinkingTypeDisabled = "disabled" // effortNone is the reasoning_effort value that switches thinking off. It is a // switch, not a strength tier, so it must never be replaced by a tier: mapping // it onto "low" would invert the caller's intent (thinking off → thinking on). effortNone = "none" ) // 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" ) // reasoningEffortAliases is DeepSeek's documented compatibility mapping: the // service accepts these aliases and applies the mapped tier. The mapping is // applied on our side too, so the request body always carries the tier the // provider documents as the effective one (design/04 §3.2 fact 1). var reasoningEffortAliases = map[string]string{ "minimal": "low", "medium": "high", "xhigh": "high", "ultra": "max", } // reasoningEffortAllowed is the per-vendor set of accepted reasoning_effort // values. A vendor absent from this table has no reasoning_effort support at all, // which is reported rather than guessed. // // There is deliberately no "round down to the nearest tier" fallback: an // unsupported value is dropped and reported instead, because silently changing it // can invert the caller's intent — the original bug mapped DeepSeek's "none" // (thinking off) onto "low" (thinking on) exactly that way. var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{ vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"), vendorDeepSeek: tierSet("none", "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. The Anthropic adapter uses the // same classification to tell a DeepSeek /anthropic endpoint (which ignores // budget_tokens) from the Anthropic endpoint. 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 } // thinkingSwitchValue maps an explicit on/off switch onto its wire value. func thinkingSwitchValue(enable bool) string { if enable { return thinkingTypeEnabled } return thinkingTypeDisabled } // normalizeDeepSeekEffort maps an effort onto the tier DeepSeek applies and // reports whether the value is one the service documents: the documented set // (none/low/high/max) passes through, the compatibility aliases map to their // official tier. func normalizeDeepSeekEffort(effort string) (string, bool) { if tier, ok := reasoningEffortAliases[effort]; ok { return tier, true } if reasoningEffortAllowed[vendorDeepSeek][effort] { return effort, true } return "", false } // resolveReasoningEffort validates effort against the vendor's whitelist and // returns the value to send; "" drops the field. An unrecognized value is never // turned into a different tier — it is dropped and reported with provider, field, // value and model, because changing it can invert the caller's intent. A DeepSeek // compatibility alias is normalized to the tier the service applies and reported. // // vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI): // the shapes that carry no such field (vLLM / DashScope) and the endpoints whose // shape is unknown drop the effort themselves and report it, so their callers // never reach this function (see reasoning_shape.go, 裁决 03.4.2). func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string { effort = strings.TrimSpace(effort) if effort == "" { return "" } allowed := reasoningEffortAllowed[vendor] if allowed[effort] { return effort } if vendor == vendorDeepSeek { if tier, ok := normalizeDeepSeekEffort(effort); ok { log.Warn("reasoning_effort alias normalized to the tier DeepSeek applies", "provider", "deepseek", "field", "reasoning_effort", "value", effort, "sent", tier, "model", model) return tier } } log.Warn("reasoning_effort dropped: unrecognized value", "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) return "" } // toolChoiceForces reports whether a tool_choice directive obliges the model to // call a tool ("required", Anthropic's "any", or a named tool). auto/none do not. func toolChoiceForces(toolChoice any) bool { switch v := toolChoice.(type) { case nil: return false case string: return strings.EqualFold(strings.TrimSpace(v), "required") case map[string]any: if forcedToolChoiceType(v["type"]) { return true } _, named := v["function"] return named case map[string]string: if forcedToolChoiceType(v["type"]) { return true } _, named := v["function"] return named } return false } // forcedToolChoiceType reports whether a tool_choice "type" value forces a call. func forcedToolChoiceType(value any) bool { t, _ := value.(string) switch strings.ToLower(strings.TrimSpace(t)) { case "required", "any", "tool": return true } return false } // reportForcedToolChoiceWithThinking warns about the DeepSeek red line that a // forced tool call — tool_choice "required"/"any"/a named tool — and thinking are // not compatible: while thinking is on (its default) the API rejects the forcing // directive with a 400, so the caller must switch thinking off explicitly first // (design/04 §3.2 fact 5). It only reports: resolution must never switch thinking // off on its own. On the OpenAI-compatible adapter the directive is not on the // wire yet (M2), so the warning is prospective there — it still records that the // requested combination can never be honoured. func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) { if !thinkingOn || !toolChoiceForces(toolChoice) { return } log.Warn("tool_choice forcing cannot be combined with thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call", "provider", provider, "field", "tool_choice", "model", model) }