mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
* fix(model): find channels for @-modified model names without the memory cache
With the channel memory cache off (no Redis and MEMORY_CACHE_ENABLED
unset), channel selection matched abilities by the exact requested name,
so a name such as deepseek-flash@effort:max found no channel. Retry with
the routing name when the exact name has no channel, as the cached path
already does.
* feat(deepseek): support V4.1 Flash, the Search and Fetch APIs, and @ reasoning modifiers
Follows the DeepSeek API update of 2026-10-17.
- Default models: deepseek-flash and deepseek-v4-pro, each with -none,
-low and -max, plus deepseek-search and deepseek-fetch. The retired
deepseek-chat, deepseek-reasoner and deepseek-v4-flash names are no
longer listed.
- Thinking: the -none/-low/-max tails also work on deepseek-flash.
@thinking and @effort now reach DeepSeek's own fields on Chat
(thinking, reasoning_effort), Anthropic (thinking, output_config.effort)
and Responses (reasoning.effort), and take precedence over the tails.
- Built-in prices: peak/off-peak expressions for deepseek-flash (also the
deepseek-v4-flash aliases) and deepseek-v4-pro, and $0.75 per 1,000
requests for deepseek-search and deepseek-fetch.
- Server-side search: searches and page fetches DeepSeek runs for Claude
Code and Codex bill at $0.75 per 1,000 under deepseek_search and
deepseek_fetch, whatever model name the client sent. Searches whose
results already arrived stay billed when a stream ends before its final
usage; this also applies to Alibaba's Anthropic endpoint.
- New endpoints POST /v1/tools/search and /v1/tools/fetch relay the
DeepSeek APIs like /v1/alpha/search. A call is charged only when
DeepSeek returns results or page content.
* fix(deepseek): price /v1/tools/search and /v1/tools/fetch by endpoint
A model named in the request body chose the billing model, so a body such
as {"query":"...","model":"deepseek-flash"} was priced as a chat model with
no tokens and the search or fetch cost nothing. Both endpoints now always
use their routing name, deepseek-search or deepseek-fetch. DeepSeek ignores
the body field, so the body is still forwarded unchanged.
* fix(deepseek): bill Claude-format page fetches at the DeepSeek Fetch price
usage.server_tool_use.web_fetch_requests always went to the web_fetch
tool price, which has no built-in value, while only searches used the
DeepSeek key. A channel can now name its own fetch key next to the search
key, and DeepSeek channels bill reported fetches under deepseek_fetch. A
channel retry clears it like the search key.
294 lines
10 KiB
Go
294 lines
10 KiB
Go
package reasoning
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
|
|
|
|
var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
|
|
|
var DeepSeekV4EffortSuffixes = []string{"-none", "-low", "-max"}
|
|
|
|
var (
|
|
legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`)
|
|
legacyClaudeModelPattern = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9._-]*$`)
|
|
legacyGeminiModelPattern = regexp.MustCompile(`^gemini-[a-z0-9][a-z0-9._-]*$`)
|
|
)
|
|
|
|
type ModelModifier struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
type ModelModifierSpec struct {
|
|
Raw string
|
|
Base string
|
|
Modifiers []ModelModifier
|
|
}
|
|
|
|
func (s ModelModifierSpec) HasModifiers() bool {
|
|
return len(s.Modifiers) > 0
|
|
}
|
|
|
|
// ParseModelModifiers removes only a contiguous trailing chain of @key:value
|
|
// segments. Other @ characters remain part of the opaque model name.
|
|
func ParseModelModifiers(modelName string) ModelModifierSpec {
|
|
spec := ModelModifierSpec{Raw: modelName, Base: modelName}
|
|
parts := strings.Split(modelName, "@")
|
|
if len(parts) < 2 {
|
|
return spec
|
|
}
|
|
|
|
firstModifier := len(parts)
|
|
for i := len(parts) - 1; i > 0; i-- {
|
|
key, value, ok := parseModelModifierSegment(parts[i])
|
|
if !ok {
|
|
break
|
|
}
|
|
firstModifier = i
|
|
spec.Modifiers = append([]ModelModifier{{Key: key, Value: value}}, spec.Modifiers...)
|
|
}
|
|
if firstModifier == len(parts) {
|
|
return spec
|
|
}
|
|
|
|
base := strings.Join(parts[:firstModifier], "@")
|
|
if base == "" {
|
|
return ModelModifierSpec{Raw: modelName, Base: modelName}
|
|
}
|
|
spec.Base = base
|
|
return spec
|
|
}
|
|
|
|
func parseModelModifierSegment(segment string) (string, string, bool) {
|
|
colon := strings.IndexByte(segment, ':')
|
|
if colon <= 0 {
|
|
return "", "", false
|
|
}
|
|
key := segment[:colon]
|
|
for i, r := range key {
|
|
letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
|
|
if i == 0 && !letter || i > 0 && !letter && (r < '0' || r > '9') && r != '_' && r != '-' {
|
|
return "", "", false
|
|
}
|
|
}
|
|
return strings.ToLower(key), segment[colon+1:], true
|
|
}
|
|
|
|
// ParseThinkingModifier maps an explicit @thinking value onto a portable
|
|
// Intent. on/adaptive/off and integer budgets (including -1) are accepted;
|
|
// values below -1 are rejected.
|
|
func ParseThinkingModifier(raw string) (Intent, bool) {
|
|
value := strings.ToLower(strings.TrimSpace(raw))
|
|
switch value {
|
|
case "on":
|
|
return Intent{Mode: ModeEnabled, Source: SourceSuffix}, true
|
|
case "adaptive":
|
|
return Intent{Mode: ModeAdaptive, Source: SourceSuffix}, true
|
|
case "off":
|
|
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
|
|
}
|
|
if budget, err := strconv.Atoi(value); err == nil {
|
|
if budget < -1 {
|
|
return Intent{}, false
|
|
}
|
|
if budget == 0 {
|
|
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
|
|
}
|
|
return Intent{
|
|
Mode: ModeEnabled,
|
|
BudgetTokens: &budget,
|
|
Source: SourceSuffix,
|
|
BudgetSource: SourceSuffix,
|
|
}, true
|
|
}
|
|
return Intent{}, false
|
|
}
|
|
|
|
func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
|
|
suffix, found := lo.Find(suffixes, func(s string) bool {
|
|
return strings.HasSuffix(modelName, s)
|
|
})
|
|
if !found {
|
|
return modelName, "", false
|
|
}
|
|
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
|
|
}
|
|
|
|
// ParseOpenAIReasoningEffortFromModelSuffix extracts an effort tail only from
|
|
// GPT and o-series model families. preserveEffortTail is consulted on the
|
|
// complete name first so real model IDs that already end in an effort word
|
|
// (for example gpt-5.1-codex-max) stay intact.
|
|
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortTail func(string) bool) (string, string) {
|
|
if preserveEffortTail != nil && preserveEffortTail(modelName) {
|
|
return "", modelName
|
|
}
|
|
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
|
|
if !ok || !legacyOpenAIModelPattern.MatchString(lastModelPathSegment(baseModel)) {
|
|
return "", modelName
|
|
}
|
|
return effort, baseModel
|
|
}
|
|
|
|
// ParseClaudeModelSuffix folds the naked claude-* aliases (-thinking,
|
|
// -nothinking, -thinking-<budget>, and effort tails such as -high) into an
|
|
// Intent. allowSuffixAlias is the host thinking-adapter toggle; when false the
|
|
// name is returned verbatim and no intent is produced.
|
|
func ParseClaudeModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
|
prefix, bare := splitModelNamespace(modelName)
|
|
if !strings.HasPrefix(bare, "claude-") {
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowSuffixAlias, true)
|
|
if err != nil || !found || !legacyClaudeModelPattern.MatchString(base) {
|
|
return modelName, Intent{}, false, err
|
|
}
|
|
return prefix + base, intent, true, nil
|
|
}
|
|
|
|
func isKnownClaudeModel(modelName string) bool {
|
|
baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
|
|
if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
|
|
baseModel = baseModel[:marker]
|
|
} else {
|
|
baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
|
|
}
|
|
knownPrefixes := []string{
|
|
"claude-fable-5", "claude-mythos-5", "claude-mythos-preview",
|
|
"claude-opus-5", "claude-sonnet-5", "claude-haiku-5", "claude-opus-4-8",
|
|
"claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6",
|
|
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
|
|
"claude-opus-4-1", "claude-opus-4-", "claude-sonnet-4-",
|
|
"claude-3-7-sonnet",
|
|
}
|
|
for _, prefix := range knownPrefixes {
|
|
if strings.HasPrefix(baseModel, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseGeminiModelSuffix is the gemini-* counterpart of ParseClaudeModelSuffix
|
|
// and honours allowSuffixAlias the same way.
|
|
func ParseGeminiModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
|
prefix, bare := splitModelNamespace(modelName)
|
|
if !strings.HasPrefix(bare, "gemini-") {
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowSuffixAlias, true)
|
|
if err != nil || !found || !legacyGeminiModelPattern.MatchString(base) {
|
|
return modelName, Intent{}, false, err
|
|
}
|
|
return prefix + base, intent, true, nil
|
|
}
|
|
|
|
// ParseKnownProviderModelSuffix extracts a canonical intent only when the
|
|
// origin identifies a provider family whose suffix vocabulary is defined by
|
|
// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
|
|
func ParseKnownProviderModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
|
bare := lastModelPathSegment(modelName)
|
|
if strings.HasPrefix(bare, "claude-") {
|
|
return ParseClaudeModelSuffix(modelName, allowSuffixAlias)
|
|
}
|
|
if strings.HasPrefix(bare, "gemini-") {
|
|
return ParseGeminiModelSuffix(modelName, allowSuffixAlias)
|
|
}
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
|
|
func splitModelNamespace(modelName string) (string, string) {
|
|
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
|
|
return modelName[:slash+1], modelName[slash+1:]
|
|
}
|
|
return "", modelName
|
|
}
|
|
|
|
func lastModelPathSegment(modelName string) string {
|
|
_, bare := splitModelNamespace(modelName)
|
|
return bare
|
|
}
|
|
|
|
func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
|
|
baseModel, _, ok, err := ParseGeminiModelSuffix(modelName, true)
|
|
return baseModel, ok && err == nil
|
|
}
|
|
|
|
// parseProviderModelSuffix recognises a provider family's naked model-name
|
|
// aliases. allowSuffixAlias gates all of them, including effort tails, so a
|
|
// disabled thinking adapter leaves the model name untouched.
|
|
func parseProviderModelSuffix(modelName string, requiredPrefix string, allowSuffixAlias bool, includeThoughts bool) (string, Intent, bool, error) {
|
|
if !allowSuffixAlias {
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
|
|
baseModel := modelName[:marker]
|
|
if !strings.HasPrefix(baseModel, requiredPrefix) {
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
|
|
if err != nil {
|
|
return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
|
|
}
|
|
intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
|
|
if includeThoughts {
|
|
value := true
|
|
intent.IncludeThoughts = &value
|
|
}
|
|
return baseModel, intent, true, nil
|
|
}
|
|
if baseModel, ok := strings.CutSuffix(modelName, "-nothinking"); ok {
|
|
return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
|
|
}
|
|
if baseModel, ok := strings.CutSuffix(modelName, "-thinking"); ok {
|
|
intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
|
|
if includeThoughts {
|
|
value := true
|
|
intent.IncludeThoughts = &value
|
|
}
|
|
return baseModel, intent, true, nil
|
|
}
|
|
|
|
suffixes := []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
|
baseModel, rawEffort, ok := TrimEffortSuffixWithSuffixes(modelName, suffixes)
|
|
if !ok || !strings.HasPrefix(baseModel, requiredPrefix) {
|
|
return modelName, Intent{}, false, nil
|
|
}
|
|
effort, err := ParseEffort(rawEffort)
|
|
if err != nil {
|
|
return modelName, Intent{}, false, err
|
|
}
|
|
intent := Intent{Effort: effort, Mode: ModeEnabled, Source: SourceSuffix}
|
|
if effort == EffortNone {
|
|
intent.Mode = ModeDisabled
|
|
} else if includeThoughts {
|
|
value := true
|
|
intent.IncludeThoughts = &value
|
|
}
|
|
return baseModel, intent, true, nil
|
|
}
|
|
|
|
// ParseDeepSeekV4ThinkingSuffix maps the -none/-low/-max tails of
|
|
// deepseek-flash and deepseek-v4-* onto DeepSeek's thinking switch and
|
|
// effort. A bare name keeps DeepSeek's default (thinking on, effort high).
|
|
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
|
|
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
|
|
if !ok || baseModel != "deepseek-flash" && !strings.HasPrefix(baseModel, "deepseek-v4-") {
|
|
return modelName, "", "", false
|
|
}
|
|
switch suffix {
|
|
case "none":
|
|
return baseModel, "disabled", "", true
|
|
case "low", "max":
|
|
return baseModel, "enabled", suffix, true
|
|
default:
|
|
return modelName, "", "", false
|
|
}
|
|
}
|