refactor: extract protocol conversion layer into standalone relaykit module (#6369)

* test(relayconvert): add golden snapshot matrix and relaykit boundary guard

Phase 0 of the relaykit extraction plan: pin byte-level output of every
registered (from,to) request/response/stream conversion route, and
forbid kit-bound packages from growing host-only imports.

* wip(relayconvert): drop gin.Context from converter signatures; add convmeta draft

Phase 1 in progress: relayconvert now takes context.Context; host media
resolver adapts gin.Context back at the service boundary.

* refactor(relayconvert): decouple converters from RelayInfo, gin, and settings

Phase 1 of the relaykit extraction plan:
- converters now depend on convmeta.Meta (implemented by RelayInfo) instead
  of *relaycommon.RelayInfo; ClaudeConvertInfo and the format guesser move
  to convmeta with aliases left behind
- host settings reach converters via a convmeta.Options snapshot built in
  RelayInfo.ConvOptions; no more model_setting/reasoning global reads inside
  the conversion layer
- effort-suffix helpers move to service/relayconvert/reasoning (old package
  forwards); chat-to-responses upgrade policy moves to service (host routing
  logic, not conversion)
- golden conversion matrix unchanged

* test(relayconvert): tighten boundary — kit packages now free of gin/setting imports

* refactor(dto): drop gin and logger dependencies

Phase 2 (part 1): dto.Request.IsStream now takes *http.Request instead of
*gin.Context (Gemini's impl reads query/path off the std request); dto's
three logger calls become common.SysError. Boundary test allowlist is now
empty — kit-bound packages import no gin/setting/logger/model.

* refactor(kit): extract dependency-free kitutil; dto/types/relayconvert stop importing common

Phase 2 of the relaykit extraction plan:
- new service/relayconvert/kitutil holds the pure helpers the kit needs
  (JSON wrappers, pointer/string/uuid/timestamp utils, MaskSensitiveInfo,
  pluggable LogInfo/LogError hooks, Debug flag)
- dto, types, and all relayconvert packages now use kitutil; their only
  remaining internal deps are dto/types/constant
- common keeps every original symbol (MaskSensitiveInfo delegates to
  kitutil) so host code is untouched; main.go routes kit logging into
  common.SysLog/SysError and mirrors DebugEnabled
- golden conversion matrix unchanged

* refactor(kit): move EndpointType/FinishReason to types; OpenRouter dialect via Options

Kit packages (dto/types/relayconvert/reasonmap) no longer import constant:
- EndpointType and finish-reason values live in types; constant re-exports
- the OpenRouter special-case in claude->openai request conversion reads
  Options.OpenRouterDialect, set by the host from the channel type;
  InitChannelMeta invalidates the cached snapshot on channel switch

* refactor: extract relaykit submodule (dto/types/relayconvert/reasonmap)

Phase 3 of the relaykit extraction plan:
- new go module github.com/QuantumNous/new-api/relaykit containing dto
  (minus task family), types, relayconvert (with convmeta/kitutil/reasoning),
  and reasonmap; host consumes it via require + replace, go.work for dev
- task-family dto (task/suno/midjourney/video) stays in the host dto
  package; dual-consumer host files alias it as taskdto
- relaykit builds and tests standalone (GOWORK=off): no host imports,
  no gin, no DB, no settings
- golden conversion matrix unchanged

* build(docker): copy relaykit/go.mod before go mod download

The local-replace submodule's go.mod must exist inside the build context
for the main module graph to resolve.

* fix: address relaykit extraction regressions

* fix: address relaykit review regressions

* docs: document Meta nil receiver contract

* fix(relaykit): fail OpenAI→Claude conversion without max_tokens; reject negative default_max_tokens

The Claude Messages API requires max_tokens (omitting it is a 400
"Field required"), but with a nil Options.Claude.DefaultMaxTokens hook
the converters silently emitted a request the upstream is guaranteed to
reject. Both OpenAI Chat and Responses → Claude conversions now return
sharedclaude.ErrMissingMaxTokens when no path (client value, default
hook, thinking-adapter floor) supplied one. Unreachable in the host,
which always configures the hook.

Host side, claude.default_max_tokens now rejects negative values at the
option API before persisting — they would wrap into huge unsigned values
during conversion. Zero stays allowed: the current API treats
max_tokens: 0 as cache pre-warming.

* fix: make Gemini safety settings read path race-free
This commit is contained in:
Calcium-Ion
2026-07-27 15:56:21 +08:00
committed by GitHub
parent f51dd4d808
commit 86ac0f7745
368 changed files with 7144 additions and 1594 deletions
+22
View File
@@ -1,9 +1,11 @@
package model_setting
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
@@ -87,3 +89,23 @@ func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int {
}
return c.DefaultMaxTokens["default"]
}
// ValidateClaudeDefaultMaxTokens validates the JSON persisted by the option
// API. Zero stays allowed — the current Messages API accepts max_tokens: 0 as
// cache pre-warming — but negative values are rejected because they would
// wrap into huge unsigned values during request conversion.
func ValidateClaudeDefaultMaxTokens(value string) error {
var settings map[string]int
if err := common.UnmarshalJsonStr(value, &settings); err != nil {
return fmt.Errorf("Claude default max tokens must be a JSON map of model to integer: %w", err)
}
if settings == nil {
return fmt.Errorf("Claude default max tokens must be a JSON map of model to integer")
}
for model, maxTokens := range settings {
if maxTokens < 0 {
return fmt.Errorf("negative Claude default max_tokens %d for %q", maxTokens, model)
}
}
return nil
}
+32
View File
@@ -3,6 +3,9 @@ package model_setting
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClaudeSettingsWriteHeadersMergesConfiguredValuesIntoSingleHeader(t *testing.T) {
@@ -58,3 +61,32 @@ func TestClaudeSettingsWriteHeadersDeduplicatesAcrossCommaSeparatedAndRepeatedVa
t.Fatalf("expected deduplicated merged header %q, got %q", expected, got[0])
}
}
func TestValidateClaudeDefaultMaxTokens(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "positive default", value: `{"default": 8192}`},
{name: "zero allowed", value: `{"default": 0}`},
{name: "zero model override allowed", value: `{"default": 8192, "claude-test": 0}`},
{name: "empty map allowed", value: `{}`},
{name: "negative default rejected", value: `{"default": -1}`, wantErr: `negative Claude default max_tokens -1 for "default"`},
{name: "negative model override rejected", value: `{"default": 8192, "claude-test": -5}`, wantErr: `negative Claude default max_tokens -5 for "claude-test"`},
{name: "non-integer rejected", value: `{"default": "high"}`, wantErr: "JSON map of model to integer"},
{name: "null rejected", value: `null`, wantErr: "JSON map of model to integer"},
{name: "malformed rejected", value: `{`, wantErr: "JSON map of model to integer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateClaudeDefaultMaxTokens(tt.value)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
+42 -3
View File
@@ -1,9 +1,23 @@
package model_setting
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
const defaultGeminiSafetySetting = "OFF"
var validGeminiSafetySettings = map[string]struct{}{
"OFF": {},
"BLOCK_NONE": {},
"BLOCK_ONLY_HIGH": {},
"BLOCK_MEDIUM_AND_ABOVE": {},
"BLOCK_LOW_AND_ABOVE": {},
"HARM_BLOCK_THRESHOLD_UNSPECIFIED": {},
}
// GeminiSettings defines Gemini model configuration. 注意bool要以enabled结尾才可以生效编辑
type GeminiSettings struct {
SafetySettings map[string]string `json:"safety_settings"`
@@ -18,7 +32,7 @@ type GeminiSettings struct {
// 默认配置
var defaultGeminiSettings = GeminiSettings{
SafetySettings: map[string]string{
"default": "OFF",
"default": defaultGeminiSafetySetting,
},
VersionSettings: map[string]string{
"default": "v1beta",
@@ -54,10 +68,35 @@ func GetGeminiSettings() *GeminiSettings {
// GetGeminiSafetySetting 获取安全设置
func GetGeminiSafetySetting(key string) string {
if value, ok := geminiSettings.SafetySettings[key]; ok {
settings := geminiSettings.SafetySettings
if value := settings[key]; value != "" {
return value
}
return geminiSettings.SafetySettings["default"]
if value := settings["default"]; value != "" {
return value
}
return defaultGeminiSafetySetting
}
// ValidateGeminiSafetySettings validates the JSON persisted by the option API.
// Empty values remain valid because read-time fallback returns the default.
func ValidateGeminiSafetySettings(value string) error {
var settings map[string]string
if err := common.UnmarshalJsonStr(value, &settings); err != nil {
return fmt.Errorf("Gemini safety settings must be a JSON string map: %w", err)
}
if settings == nil {
return fmt.Errorf("Gemini safety settings must be a JSON string map")
}
for category, threshold := range settings {
if threshold == "" {
continue
}
if _, ok := validGeminiSafetySettings[threshold]; !ok {
return fmt.Errorf("invalid Gemini safety threshold %q for %q", threshold, category)
}
}
return nil
}
// GetGeminiVersionSetting 获取版本设置
+99
View File
@@ -0,0 +1,99 @@
package model_setting
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeminiSafetySettingsReadNormalization(t *testing.T) {
original := geminiSettings.SafetySettings
t.Cleanup(func() {
geminiSettings.SafetySettings = original
})
tests := []struct {
name string
settings map[string]string
key string
want string
}{
{
name: "nil map gets OFF default",
settings: nil,
key: "HARM_CATEGORY_HATE_SPEECH",
want: "OFF",
},
{
name: "missing default gets OFF without replacing existing values",
settings: map[string]string{
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_SOME",
},
key: "HARM_CATEGORY_HATE_SPEECH",
want: "BLOCK_SOME",
},
{
name: "empty default gets OFF",
settings: map[string]string{
"default": "",
},
key: "HARM_CATEGORY_HATE_SPEECH",
want: "OFF",
},
{
name: "empty override falls back to configured default",
settings: map[string]string{
"default": "BLOCK_ONLY_HIGH",
"HARM_CATEGORY_HATE_SPEECH": "",
},
key: "HARM_CATEGORY_HATE_SPEECH",
want: "BLOCK_ONLY_HIGH",
},
{
name: "historical invalid nonempty default is preserved",
settings: map[string]string{
"default": "BLOCK_SOME",
},
key: "HARM_CATEGORY_HATE_SPEECH",
want: "BLOCK_SOME",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
geminiSettings.SafetySettings = test.settings
assert.Equal(t, test.want, GetGeminiSafetySetting(test.key))
})
}
}
func TestValidateGeminiSafetySettings(t *testing.T) {
valid := []string{
`{}`,
`{"default":""}`,
`{"HARM_CATEGORY_HATE_SPEECH":""}`,
`{"default":"OFF"}`,
`{"default":"BLOCK_NONE"}`,
`{"default":"BLOCK_ONLY_HIGH"}`,
`{"default":"BLOCK_MEDIUM_AND_ABOVE"}`,
`{"default":"BLOCK_LOW_AND_ABOVE"}`,
`{"default":"HARM_BLOCK_THRESHOLD_UNSPECIFIED"}`,
}
for _, value := range valid {
require.NoError(t, ValidateGeminiSafetySettings(value), value)
}
invalid := []string{
`null`,
`[]`,
`{"default":1}`,
`{"default":"BLOCK_SOME"}`,
`{"default":" off "}`,
`{"default":`,
}
for _, value := range invalid {
assert.Error(t, ValidateGeminiSafetySettings(value), value)
}
}
@@ -6,7 +6,7 @@ import (
"strconv"
"strings"
"github.com/QuantumNous/new-api/types"
"github.com/QuantumNous/new-api/relaykit/types"
)
type StatusCodeRange struct {
+12 -12
View File
@@ -6,21 +6,21 @@ import (
)
var (
WaffoEnabled bool
WaffoApiKey string
WaffoPrivateKey string
WaffoPublicCert string
WaffoSandboxPublicCert string
WaffoSandboxApiKey string
WaffoSandboxPrivateKey string
WaffoSandbox bool
WaffoMerchantId string
WaffoEnabled bool
WaffoApiKey string
WaffoPrivateKey string
WaffoPublicCert string
WaffoSandboxPublicCert string
WaffoSandboxApiKey string
WaffoSandboxPrivateKey string
WaffoSandbox bool
WaffoMerchantId string
WaffoNotifyUrl string
WaffoReturnUrl string
WaffoSubscriptionReturnUrl string
WaffoCurrency string
WaffoUnitPrice float64 = 1.0
WaffoMinTopUp int = 1
WaffoCurrency string
WaffoUnitPrice float64 = 1.0
WaffoMinTopUp int = 1
)
// GetWaffoPayMethods 从 options 读取 Waffo 支付方式配置
+1 -1
View File
@@ -1,7 +1,7 @@
package ratio_setting
import (
"github.com/QuantumNous/new-api/types"
"github.com/QuantumNous/new-api/relaykit/types"
)
var defaultCacheRatio = map[string]float64{
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"errors"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/types"
)
var defaultGroupRatio = map[string]float64{
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
)
// from songquanpeng/one-api
+14 -46
View File
@@ -1,51 +1,19 @@
// Package reasoning re-exports the pure model-name effort-suffix helpers,
// which moved to the conversion kit (service/relayconvert/reasoning) as part
// of the relaykit extraction. Host code keeps importing this path unchanged.
package reasoning
import (
"strings"
import kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
var (
EffortSuffixes = kitreasoning.EffortSuffixes
OpenAIEffortSuffixes = kitreasoning.OpenAIEffortSuffixes
DeepSeekV4EffortSuffixes = kitreasoning.DeepSeekV4EffortSuffixes
)
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"}
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
// TrimEffortSuffix -> modelName level(low) exists
func TrimEffortSuffix(modelName string) (string, string, bool) {
return TrimEffortSuffixWithSuffixes(modelName, EffortSuffixes)
}
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
}
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
if !ok {
return "", modelName
}
return effort, baseModel
}
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
return modelName, "", "", false
}
switch suffix {
case "none":
return baseModel, "disabled", "", true
case "max":
return baseModel, "enabled", "max", true
default:
return modelName, "", "", false
}
}
var (
TrimEffortSuffix = kitreasoning.TrimEffortSuffix
TrimEffortSuffixWithSuffixes = kitreasoning.TrimEffortSuffixWithSuffixes
ParseOpenAIReasoningEffortFromModelSuffix = kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix
ParseDeepSeekV4ThinkingSuffix = kitreasoning.ParseDeepSeekV4ThinkingSuffix
)