Files
new-api/relaykit/relayconvert/reasoning/suffix.go
T
Calcium-Ion 6cf0d90b5f feat(deepseek): support V4.1 Flash, the Search and Fetch APIs, and @ reasoning modifiers (#7867)
* 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.
2026-10-17 23:24:33 +08:00

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
}
}