mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-12 06:40:40 +00:00
merge: 合入 origin/main,与新增的 service_tier 字段并存
主线在 ResponsesResponse 与两处流式事件上新增了 service_tier;本分支新增的 created_at 与之并列,无语义冲突。同时跟进 ChatCompletionsResponseToResponses 新增的 functionTools 形参。
This commit is contained in:
commit
9192426d2e
818 files changed
+83522
-5800
No files matched your search
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
@@ -61,6 +61,10 @@ const (
|
||||
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
|
||||
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
|
||||
|
||||
// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。
|
||||
// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。
|
||||
const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
@@ -99,6 +103,18 @@ type Config struct {
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
Plugins PluginConfig `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
// PluginConfig 控制管理员手动上传的本地进程插件。
|
||||
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
|
||||
type PluginConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
AllowUnsigned bool `mapstructure:"allow_unsigned"`
|
||||
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
|
||||
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
|
||||
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
|
||||
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -830,6 +846,53 @@ type ProxyFallbackConfig struct {
|
||||
|
||||
type ProxyProbeConfig struct {
|
||||
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
|
||||
// URLs 按优先级排列的自定义探测 URL 列表。
|
||||
// 留空时使用内置默认列表(ip-api → ipify)。
|
||||
// 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。
|
||||
URLs []ProbeURLConfig `mapstructure:"urls"`
|
||||
}
|
||||
|
||||
// ProbeURLConfig 描述一个探测端点及其响应解析方式。
|
||||
type ProbeURLConfig struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace"
|
||||
}
|
||||
|
||||
func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) {
|
||||
if len(targets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
normalized := make([]ProbeURLConfig, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
rawURL := strings.TrimSpace(target.URL)
|
||||
parser := strings.ToLower(strings.TrimSpace(target.Parser))
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("entry %d: url is required", i)
|
||||
}
|
||||
if parser == "" {
|
||||
return nil, fmt.Errorf("entry %d: parser is required", i)
|
||||
}
|
||||
switch parser {
|
||||
case "ip-api", "ipify", "chatgpt-trace":
|
||||
default:
|
||||
return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("entry %d: url scheme must be http or https", i)
|
||||
}
|
||||
|
||||
normalized = append(normalized, ProbeURLConfig{
|
||||
URL: rawURL,
|
||||
Parser: parser,
|
||||
})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
type BillingConfig struct {
|
||||
@@ -887,6 +950,9 @@ type GatewayConfig struct {
|
||||
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
|
||||
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
|
||||
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
|
||||
// GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok.
|
||||
// A zero value uses the provider-safe default instead of the generic gateway timeout.
|
||||
GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"`
|
||||
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
|
||||
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
|
||||
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
|
||||
@@ -898,6 +964,8 @@ type GatewayConfig struct {
|
||||
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
|
||||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||||
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
|
||||
// 上游模型列表响应体读取上限(字节)
|
||||
ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"`
|
||||
// 代理探测响应体读取上限(字节)
|
||||
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
|
||||
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
|
||||
@@ -1025,6 +1093,10 @@ type GatewayConfig struct {
|
||||
|
||||
// Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings.
|
||||
Grok GatewayGrokConfig `mapstructure:"grok"`
|
||||
|
||||
// CNProviders: 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
|
||||
// 仅作用于 payg(按量付费)账号:周期探测余额,低于阈值则临时停调。
|
||||
CNProviders GatewayCNProvidersConfig `mapstructure:"cn_providers"`
|
||||
}
|
||||
|
||||
// GatewayGrokConfig holds Grok-specific gateway scheduling knobs.
|
||||
@@ -1057,6 +1129,18 @@ type GatewayGrokConfig struct {
|
||||
FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"`
|
||||
}
|
||||
|
||||
// GatewayCNProvidersConfig 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
|
||||
//
|
||||
// 仅作用于 payg(按量付费)账号(kimi/deepseek 有公开余额端点;zhipu 无,仅靠响应式 429/402)。
|
||||
// - balance_check_enabled: 是否启用周期余额检测(默认 true)
|
||||
// - balance_threshold: 余额低于此值(账户货币单位,默认 0.5)触发临时停调
|
||||
// - balance_check_interval_minutes: 余额检测周期(分钟,默认 10)
|
||||
type GatewayCNProvidersConfig struct {
|
||||
BalanceCheckEnabled bool `mapstructure:"balance_check_enabled"`
|
||||
BalanceThreshold float64 `mapstructure:"balance_threshold"`
|
||||
BalanceCheckIntervalMinutes int `mapstructure:"balance_check_interval_minutes"`
|
||||
}
|
||||
|
||||
type GatewayLiveConfig struct {
|
||||
// MaxSessionDurationSeconds 是 Live 会话的硬上限。
|
||||
MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"`
|
||||
@@ -1703,6 +1787,10 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
// 环境变量支持
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
if tz, ok := os.LookupEnv("TZ"); ok && strings.TrimSpace(tz) != "" {
|
||||
// AutomaticEnv 会先把 timezone 映射到 TIMEZONE;显式 Set 保证标准 TZ 变量优先。
|
||||
viper.Set("timezone", strings.TrimSpace(tz))
|
||||
}
|
||||
if err := viper.BindEnv("server.enable_server_timing", "ENABLE_SERVER_TIMING"); err != nil {
|
||||
return nil, fmt.Errorf("bind ENABLE_SERVER_TIMING: %w", err)
|
||||
}
|
||||
@@ -2199,6 +2287,14 @@ func setDefaults() {
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
|
||||
viper.SetDefault("plugins.data_dir", "")
|
||||
viper.SetDefault("plugins.allow_unsigned", false)
|
||||
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
|
||||
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
|
||||
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
|
||||
viper.SetDefault("plugins.start_timeout_seconds", 15)
|
||||
|
||||
// Timezone (default to Asia/Shanghai for Chinese users)
|
||||
viper.SetDefault("timezone", "Asia/Shanghai")
|
||||
|
||||
@@ -2260,6 +2356,7 @@ func setDefaults() {
|
||||
// Gateway
|
||||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||
viper.SetDefault("gateway.openai_response_header_timeout", 0)
|
||||
viper.SetDefault("gateway.grok_response_header_timeout", 120)
|
||||
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.log_upstream_error_body", true)
|
||||
@@ -2351,6 +2448,10 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95)
|
||||
viper.SetDefault("gateway.grok.free_quota_window_hours", 24)
|
||||
viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 60)
|
||||
// 国产供应商余额检测(kimi/deepseek payg;zhipu 无余额端点,仅靠响应式 429/402)。
|
||||
viper.SetDefault("gateway.cn_providers.balance_check_enabled", true)
|
||||
viper.SetDefault("gateway.cn_providers.balance_threshold", 0.5)
|
||||
viper.SetDefault("gateway.cn_providers.balance_check_interval_minutes", 10)
|
||||
viper.SetDefault("gateway.image_concurrency.enabled", false)
|
||||
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
|
||||
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
|
||||
@@ -2361,6 +2462,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
|
||||
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
|
||||
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
|
||||
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
|
||||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||||
@@ -2545,6 +2647,20 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
|
||||
c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
|
||||
proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("security.proxy_probe.urls: %w", err)
|
||||
}
|
||||
c.Security.ProxyProbe.URLs = proxyProbeURLs
|
||||
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
|
||||
}
|
||||
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
|
||||
}
|
||||
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
|
||||
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
|
||||
}
|
||||
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
|
||||
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
|
||||
}
|
||||
@@ -3156,6 +3272,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ModelsListReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.models_list_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
|
||||
}
|
||||
@@ -3165,6 +3284,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
|
||||
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
|
||||
}
|
||||
if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 {
|
||||
return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds")
|
||||
}
|
||||
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
|
||||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
|
||||
|
||||
@@ -23,6 +23,45 @@ func resetViperWithJWTSecret(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
}
|
||||
|
||||
func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
|
||||
}
|
||||
|
||||
func TestLoadTimezonePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileTimezone string
|
||||
timezoneEnv string
|
||||
tzEnv string
|
||||
want string
|
||||
}{
|
||||
{name: "default", want: "Asia/Shanghai"},
|
||||
{name: "config_file", fileTimezone: "Europe/London", want: "Europe/London"},
|
||||
{name: "timezone_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", want: "UTC"},
|
||||
{name: "tz_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", tzEnv: "America/New_York", want: "America/New_York"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("TIMEZONE", tt.timezoneEnv)
|
||||
t.Setenv("TZ", tt.tzEnv)
|
||||
if tt.fileTimezone != "" {
|
||||
configFile := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(configFile, []byte("timezone: "+tt.fileTimezone+"\n"), 0o600))
|
||||
t.Setenv("CONFIG_FILE", configFile)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, cfg.Timezone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadServerTimingConfig(t *testing.T) {
|
||||
t.Run("disabled by default", func(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
@@ -521,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
|
||||
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -1762,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
|
||||
wantErr: "gateway.text_max_body_size",
|
||||
},
|
||||
{
|
||||
name: "gateway models list read limit",
|
||||
mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 },
|
||||
wantErr: "gateway.models_list_read_max_bytes",
|
||||
},
|
||||
{
|
||||
name: "gateway response header timeout",
|
||||
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
|
||||
|
||||
@@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
|
||||
// is out of scope here — such settings need a config file either way.
|
||||
continue
|
||||
}
|
||||
if ft.Kind() == reflect.Slice {
|
||||
elem := ft.Elem()
|
||||
for elem.Kind() == reflect.Ptr {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
if elem.Kind() == reflect.Struct {
|
||||
// AutomaticEnv exposes one string value. Viper's string-to-slice
|
||||
// hook can populate scalar slices, but it cannot decode a string
|
||||
// into []struct. Registering a default would turn silent ignore
|
||||
// into a startup unmarshal error, so structured slices remain
|
||||
// config-file-only just like maps.
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[strings.ToLower(key)] = ft.String()
|
||||
}
|
||||
}
|
||||
@@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
|
||||
// were lost, silently disabling async image tasks for env-driven deployments.
|
||||
//
|
||||
// When this fails, register a zero-valued default in setEnvReachableDefaults
|
||||
// for each reported key.
|
||||
// for each reported scalar key. Maps and slices of structs are config-file-only.
|
||||
func TestConfigKeysAreEnvReachable(t *testing.T) {
|
||||
bound := map[string]string{}
|
||||
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build unit
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeProxyProbeURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeProxyProbeURLs([]ProbeURLConfig{
|
||||
{URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "},
|
||||
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ProbeURLConfig{
|
||||
{URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"},
|
||||
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target ProbeURLConfig
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"},
|
||||
{name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"},
|
||||
{name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"},
|
||||
{name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"},
|
||||
{name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target})
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// 渠道监控「配额模式」的归一化配额快照类型。
|
||||
//
|
||||
// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务
|
||||
// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService),
|
||||
// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化
|
||||
// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。
|
||||
//
|
||||
// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做
|
||||
// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。
|
||||
|
||||
// MonitorQuotaTier 单个用量窗口的快照。
|
||||
//
|
||||
// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应):
|
||||
// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan)
|
||||
// - "7d" 7 天窗口(Claude/Codex)
|
||||
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
|
||||
// - "7d-fable" Claude 7 天 Fable 独立额度
|
||||
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
|
||||
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
|
||||
// - "30d" 30 天窗口(Grok 月度)
|
||||
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
|
||||
//
|
||||
// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、
|
||||
// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/
|
||||
// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。
|
||||
type MonitorQuotaTier struct {
|
||||
Window string `json:"window"`
|
||||
Label string `json:"label,omitempty"`
|
||||
UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算
|
||||
Used float64 `json:"used,omitempty"`
|
||||
Limit float64 `json:"limit,omitempty"`
|
||||
ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空
|
||||
}
|
||||
|
||||
// MonitorQuotaSnapshot 一次配额查询的完整快照。
|
||||
//
|
||||
// Source 取值:
|
||||
// - "usage" 海外平台(AccountUsageService.GetUsage)
|
||||
// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage)
|
||||
// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance)
|
||||
type MonitorQuotaSnapshot struct {
|
||||
Source string `json:"source"`
|
||||
Success bool `json:"success"`
|
||||
Tiers []MonitorQuotaTier `json:"tiers,omitempty"`
|
||||
Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额
|
||||
Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD)
|
||||
Currency string `json:"currency,omitempty"` // 主余额币种
|
||||
PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level)
|
||||
// BalanceLow 余额低于阈值或账号被上游标记不可用(仅 cn_balance 来源)。
|
||||
// 抓取器按 Gateway.CNProviders.BalanceThreshold 判定,口径与账号停调
|
||||
// (CNProviderBalanceCheckService.checkOne)一致:任一币种达标即健康。
|
||||
BalanceLow bool `json:"balance_low,omitempty"`
|
||||
// CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误),
|
||||
// 检测状态据此推导 failed 而非 error。
|
||||
CredentialInvalid bool `json:"credential_invalid,omitempty"`
|
||||
Error string `json:"error,omitempty"` // Success=false 时的错误摘要
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// MonitorBalance 单币种余额条目。
|
||||
type MonitorBalance struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
@@ -23,7 +23,28 @@ const (
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
PlatformComposite = "composite"
|
||||
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
|
||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformComposite = "composite"
|
||||
)
|
||||
|
||||
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
|
||||
// 存储于 credentials["account_mode"],决定 base_url 预设与额度监控方式。
|
||||
const (
|
||||
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
|
||||
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
|
||||
)
|
||||
|
||||
// API protocol constants 国产供应商的上游 API 协议维度。存储于
|
||||
// credentials["api_protocol"],与 account_mode 正交:协议决定转发端点与格式,
|
||||
// 模式决定额度监控方式。同协议请求零转换直通;跨协议组合才走转换链。
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
@@ -84,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
|
||||
// Claude 详细版本 ID 映射
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
|
||||
// Claude Haiku → Sonnet(无 Haiku 支持)
|
||||
"claude-haiku-4-5": "claude-sonnet-4-6",
|
||||
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
|
||||
|
||||
@@ -43,6 +43,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
|
||||
}
|
||||
for model, want := range cases {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != want {
|
||||
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1025,7 +1025,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
|
||||
// 网关会按"现状即证据"默认走 Responses。
|
||||
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
|
||||
if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
|
||||
if account == nil || account.Type != service.AccountTypeAPIKey ||
|
||||
(account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
|
||||
return
|
||||
}
|
||||
if h.accountTestService == nil {
|
||||
@@ -2613,8 +2614,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
|
||||
// Handle Gemini accounts
|
||||
if account.IsGemini() {
|
||||
// For OAuth accounts: return default Gemini models
|
||||
// Consumer Google One OAuth still uses the legacy Gemini CLI / Code
|
||||
// Assist channel. Do not advertise newer 3.x or image models that the
|
||||
// channel cannot serve.
|
||||
if account.IsOAuth() {
|
||||
if account.IsGeminiGoogleOne() {
|
||||
response.Success(c, geminicli.GoogleOneModels)
|
||||
return
|
||||
}
|
||||
response.Success(c, geminicli.DefaultModels)
|
||||
return
|
||||
}
|
||||
@@ -2767,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2786,29 +2795,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||||
// POST /api/v1/admin/accounts/models/sync-upstream-preview
|
||||
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
var req struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
modelMapping := make(map[string]any, len(req.ModelMapping))
|
||||
for sourceModel, upstreamModel := range req.ModelMapping {
|
||||
modelMapping[sourceModel] = upstreamModel
|
||||
}
|
||||
|
||||
tempAccount := &service.Account{
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: map[string]any{
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"model_mapping": modelMapping,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2817,13 +2832,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2836,7 +2853,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
|
||||
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
}
|
||||
|
||||
type syncUpstreamHTTPUpstream struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
resp *http.Response
|
||||
responses []*http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
if u.err != nil {
|
||||
return nil, u.err
|
||||
}
|
||||
if len(u.responses) > 0 {
|
||||
resp := u.responses[0]
|
||||
u.responses = u.responses[1:]
|
||||
return resp, nil
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
|
||||
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -287,6 +294,42 @@ func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(
|
||||
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 45,
|
||||
Name: "google-one",
|
||||
Platform: service.PlatformGemini,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"oauth_type": "google_one",
|
||||
},
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
ids := make([]string, 0, len(resp.Data))
|
||||
for _, model := range resp.Data {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids)
|
||||
require.NotContains(t, ids, "gemini-3.5-flash")
|
||||
require.NotContains(t, ids, "gemini-2.5-flash-image")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -311,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
|
||||
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"custom-thinking-model",
|
||||
"reasoning":true,
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":["low","high"],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
|
||||
metadata := resp.Data.Metadata["custom-thinking-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
}
|
||||
|
||||
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
|
||||
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"configured-provider": {
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"id": "glm-5.3",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","high"]}],
|
||||
"modalities": {"input":["text"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/models/sync-upstream-preview",
|
||||
strings.NewReader(`{
|
||||
"platform":"openai",
|
||||
"type":"apikey",
|
||||
"base_url":"https://provider.example/v1",
|
||||
"api_key":"key",
|
||||
"model_mapping":{"public-glm":"glm-5.3"}
|
||||
}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
|
||||
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -341,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
|
||||
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
|
||||
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
|
||||
}
|
||||
|
||||
// Scenario: 能力补全失败显示部分成功。
|
||||
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 46,
|
||||
Name: "opencode-id-only-model-list",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Models []string `json:"models"`
|
||||
Warnings []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
|
||||
require.Len(t, resp.Data.Warnings, 1)
|
||||
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
|
||||
}
|
||||
@@ -57,29 +57,48 @@ type updateChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodRequest `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodRequest struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
}
|
||||
|
||||
type pricingIntervalRequest struct {
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
|
||||
CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"`
|
||||
CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type accountStatsPricingRuleRequest struct {
|
||||
@@ -108,31 +127,50 @@ type channelResponse struct {
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodResponse `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodResponse struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
}
|
||||
|
||||
type pricingIntervalResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
|
||||
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type accountStatsPricingRuleResponse struct {
|
||||
@@ -224,29 +262,55 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricingResponse {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
periods := make([]channelTimePricingPeriodResponse, 0, len(value.Periods))
|
||||
for _, period := range value.Periods {
|
||||
periods = append(periods, channelTimePricingPeriodResponse{
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &channelTimePricingResponse{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
return pricingIntervalResponse{
|
||||
ID: iv.ID,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
SortOrder: iv.SortOrder,
|
||||
ID: iv.ID,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
CacheWriteMultiplier: iv.CacheWriteMultiplier,
|
||||
CacheReadMultiplier: iv.CacheReadMultiplier,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
SortOrder: iv.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing {
|
||||
func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing {
|
||||
result := make([]service.ChannelModelPricing, 0, len(reqs))
|
||||
for _, r := range reqs {
|
||||
billingMode := service.BillingMode(r.BillingMode)
|
||||
@@ -256,18 +320,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
|
||||
platform := r.Platform
|
||||
intervals := make([]service.PricingInterval, 0, len(r.Intervals))
|
||||
for _, iv := range r.Intervals {
|
||||
var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64
|
||||
if allowChannelMultipliers {
|
||||
inputMultiplier = iv.InputMultiplier
|
||||
outputMultiplier = iv.OutputMultiplier
|
||||
cacheWriteMultiplier = iv.CacheWriteMultiplier
|
||||
cacheReadMultiplier = iv.CacheReadMultiplier
|
||||
}
|
||||
intervals = append(intervals, service.PricingInterval{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
SortOrder: iv.SortOrder,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: inputMultiplier,
|
||||
OutputMultiplier: outputMultiplier,
|
||||
CacheWriteMultiplier: cacheWriteMultiplier,
|
||||
CacheReadMultiplier: cacheReadMultiplier,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
SortOrder: iv.SortOrder,
|
||||
})
|
||||
}
|
||||
var fastMultiplier, flexMultiplier *float64
|
||||
if allowChannelMultipliers {
|
||||
fastMultiplier = r.FastMultiplier
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
@@ -276,21 +356,43 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func timePricingRequestToService(value *channelTimePricingRequest) *service.ChannelTimePricing {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
periods := make([]service.ChannelTimePricingPeriod, 0, len(value.Periods))
|
||||
for _, period := range value.Periods {
|
||||
periods = append(periods, service.ChannelTimePricingPeriod{
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &service.ChannelTimePricing{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
|
||||
return service.AccountStatsPricingRule{
|
||||
Name: r.Name,
|
||||
GroupIDs: r.GroupIDs,
|
||||
AccountIDs: r.AccountIDs,
|
||||
Pricing: pricingRequestToService(r.Pricing),
|
||||
Pricing: pricingRequestToService(r.Pricing, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +453,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
pricing := pricingRequestToService(req.ModelPricing)
|
||||
pricing := pricingRequestToService(req.ModelPricing, true)
|
||||
// Main model_pricing requires a platform; default to anthropic for backward compatibility.
|
||||
for i := range pricing {
|
||||
if pricing[i].Platform == "" {
|
||||
@@ -425,7 +527,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
||||
ApplyPricingToAccountStats: req.ApplyPricingToAccountStats,
|
||||
}
|
||||
if req.ModelPricing != nil {
|
||||
pricing := pricingRequestToService(*req.ModelPricing)
|
||||
pricing := pricingRequestToService(*req.ModelPricing, true)
|
||||
for i := range pricing {
|
||||
if pricing[i].Platform == "" {
|
||||
pricing[i].Platform = service.PlatformAnthropic
|
||||
@@ -512,9 +614,12 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformAnthropic: "anthropic",
|
||||
service.PlatformOpenAI: "openai",
|
||||
service.PlatformGemini: "google",
|
||||
service.PlatformGemini: "gemini",
|
||||
service.PlatformAntigravity: "anthropic",
|
||||
service.PlatformGrok: "xai",
|
||||
service.PlatformKimi: "moonshot",
|
||||
service.PlatformZhipu: "zhipu",
|
||||
service.PlatformDeepseek: "deepseek",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := pricingRequestToService([]channelModelPricingRequest{tt.req})
|
||||
result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true)
|
||||
require.Len(t, result, 1)
|
||||
switch tt.wantField {
|
||||
case "BillingMode":
|
||||
@@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := pricingRequestToService(reqs)
|
||||
result := pricingRequestToService(reqs, true)
|
||||
require.Len(t, result, 1)
|
||||
r := result[0]
|
||||
require.Equal(t, "openai", r.Platform)
|
||||
@@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := pricingRequestToService(reqs)
|
||||
result := pricingRequestToService(reqs, true)
|
||||
require.Len(t, result, 1)
|
||||
require.Len(t, result[0].Intervals, 2)
|
||||
|
||||
@@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPricingRequestToService_EmptySlice(t *testing.T) {
|
||||
result := pricingRequestToService([]channelModelPricingRequest{})
|
||||
result := pricingRequestToService([]channelModelPricingRequest{}, true)
|
||||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := pricingRequestToService(reqs)
|
||||
result := pricingRequestToService(reqs, true)
|
||||
require.Len(t, result, 1)
|
||||
r := result[0]
|
||||
require.Nil(t, r.InputPrice)
|
||||
@@ -421,6 +421,89 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
|
||||
require.Nil(t, r.PerRequestPrice)
|
||||
}
|
||||
|
||||
func TestPricingRequestToService_TimePricing(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
TimePricing: &channelTimePricingRequest{
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []channelTimePricingPeriodRequest{{
|
||||
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
got := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
|
||||
require.True(t, got[0].TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
func TestPricingRequestToService_TimePricingNil(t *testing.T) {
|
||||
got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true)
|
||||
require.Nil(t, got[0].TimePricing)
|
||||
}
|
||||
|
||||
// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃,
|
||||
// 避免渠道倍率意外污染账号成本口径。
|
||||
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
Intervals: []pricingIntervalRequest{{
|
||||
MinTokens: 272000,
|
||||
InputMultiplier: float64Ptr(2),
|
||||
OutputMultiplier: float64Ptr(1.5),
|
||||
CacheWriteMultiplier: float64Ptr(2),
|
||||
CacheReadMultiplier: float64Ptr(2),
|
||||
}},
|
||||
}
|
||||
|
||||
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
|
||||
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
|
||||
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier)
|
||||
|
||||
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
|
||||
require.Nil(t, dropped[0].FastMultiplier)
|
||||
require.Nil(t, dropped[0].FlexMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier)
|
||||
// 非倍率字段不受开关影响
|
||||
require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens)
|
||||
}
|
||||
|
||||
func TestPricingToResponse_TimePricing(t *testing.T) {
|
||||
got := pricingToResponse(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
TimePricing: &service.ChannelTimePricing{
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []service.ChannelTimePricingPeriod{{
|
||||
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
require.NotNil(t, got.TimePricing)
|
||||
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
|
||||
require.True(t, got.TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
func TestPricingToResponse_TimePricingNil(t *testing.T) {
|
||||
got := pricingToResponse(&service.ChannelModelPricing{})
|
||||
require.Nil(t, got.TimePricing)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. SyncPricingModels handler
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -459,7 +542,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
svc := service.NewPricingService(nil, nil)
|
||||
router := setupSyncPricingModelsRouter(svc)
|
||||
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} {
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"required,max=500"`
|
||||
APIKey string `json:"api_key" binding:"required,max=2000"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
PrimaryModel string `json:"primary_model" binding:"max=200"`
|
||||
ExtraModels []string `json:"extra_models"`
|
||||
GroupName string `json:"group_name" binding:"max=100"`
|
||||
@@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct {
|
||||
ExtraHeaders map[string]string `json:"extra_headers"`
|
||||
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
|
||||
BodyOverride map[string]any `json:"body_override"`
|
||||
|
||||
// CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
|
||||
// 可空(条件必填校验在 service 层按模式分支)。
|
||||
CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
|
||||
// AccountID: 配额模式关联的账号 ID。
|
||||
AccountID *int64 `json:"account_id"`
|
||||
}
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
@@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct {
|
||||
ExtraHeaders *map[string]string `json:"extra_headers"`
|
||||
BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
|
||||
BodyOverride *map[string]any `json:"body_override"`
|
||||
|
||||
// CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
|
||||
CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
|
||||
AccountID *int64 `json:"account_id"`
|
||||
}
|
||||
|
||||
type channelMonitorResponse struct {
|
||||
@@ -101,25 +112,33 @@ type channelMonitorResponse struct {
|
||||
ExtraHeaders map[string]string `json:"extra_headers"`
|
||||
BodyOverrideMode string `json:"body_override_mode"`
|
||||
BodyOverride map[string]any `json:"body_override"`
|
||||
|
||||
// 配额模式:check_mode + 关联账号 + 主模型最近配额快照
|
||||
// (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
|
||||
CheckMode string `json:"check_mode"`
|
||||
AccountID *int64 `json:"account_id"`
|
||||
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
|
||||
}
|
||||
|
||||
type channelMonitorCheckResultResponse struct {
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
|
||||
}
|
||||
|
||||
type channelMonitorHistoryItemResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
ID int64 `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
|
||||
}
|
||||
|
||||
// maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
|
||||
@@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
|
||||
ExtraHeaders: headers,
|
||||
BodyOverrideMode: m.BodyOverrideMode,
|
||||
BodyOverride: m.BodyOverride,
|
||||
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
|
||||
CheckMode: m.CheckMode,
|
||||
AccountID: m.AccountID,
|
||||
// PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
|
||||
// 由 List handler 在批量聚合后填充。
|
||||
}
|
||||
if m.LastCheckedAt != nil {
|
||||
s := m.LastCheckedAt.UTC().Format(time.RFC3339)
|
||||
@@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp
|
||||
PingLatencyMs: r.PingLatencyMs,
|
||||
Message: r.Message,
|
||||
CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
|
||||
Quota: r.Quota,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito
|
||||
PingLatencyMs: e.PingLatencyMs,
|
||||
Message: e.Message,
|
||||
CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
|
||||
Quota: e.Quota,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta
|
||||
resp.PrimaryStatus = summary.PrimaryStatus
|
||||
resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
|
||||
resp.Availability7d = summary.Availability7d
|
||||
resp.LatestQuota = summary.LatestQuota
|
||||
resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
|
||||
for _, e := range summary.ExtraModels {
|
||||
resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
|
||||
@@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
|
||||
ExtraHeaders: req.ExtraHeaders,
|
||||
BodyOverrideMode: req.BodyOverrideMode,
|
||||
BodyOverride: req.BodyOverride,
|
||||
CheckMode: req.CheckMode,
|
||||
AccountID: req.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
|
||||
ExtraHeaders: req.ExtraHeaders,
|
||||
BodyOverrideMode: req.BodyOverrideMode,
|
||||
BodyOverride: req.BodyOverride,
|
||||
CheckMode: req.CheckMode,
|
||||
AccountID: req.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CNProviderHandler 暴露国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
|
||||
//
|
||||
// - GET /admin/cn-providers/accounts/:id/quota Coding Plan 滚动窗口用量(kimi/zhipu)
|
||||
// - GET /admin/cn-providers/accounts/:id/balance payg 账号余额(kimi/deepseek)
|
||||
//
|
||||
// 智谱(zhipu)无余额端点,故同一账号仅 quota 或 balance 其一可用:服务端按账号
|
||||
// platform + account_mode 校验并返回明确错误(见 CNProvider*Service 的 load*Account)。
|
||||
type CNProviderHandler struct {
|
||||
quotaService *service.CNProviderQuotaService
|
||||
balanceService *service.CNProviderBalanceService
|
||||
}
|
||||
|
||||
func NewCNProviderHandler(
|
||||
quotaService *service.CNProviderQuotaService,
|
||||
balanceService *service.CNProviderBalanceService,
|
||||
) *CNProviderHandler {
|
||||
return &CNProviderHandler{
|
||||
quotaService: quotaService,
|
||||
balanceService: balanceService,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryQuota 查询 Coding Plan 滚动窗口用量(5h + weekly)。
|
||||
func (h *CNProviderHandler) QueryQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h == nil || h.quotaService == nil {
|
||||
response.BadRequest(c, "cn provider quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// QueryBalance 查询 payg 账号余额。
|
||||
func (h *CNProviderHandler) QueryBalance(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h == nil || h.balanceService == nil {
|
||||
response.BadRequest(c, "cn provider balance service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.balanceService.QueryBalance(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
@@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
// ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。
|
||||
//nolint:staticcheck // SA4023
|
||||
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
if err != nil { //nolint:staticcheck // SA4023
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -166,7 +166,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -234,7 +234,7 @@ type UpdateGroupRequest struct {
|
||||
type CompositeRouteRequest struct {
|
||||
PublicModel string `json:"public_model" binding:"required"`
|
||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||
Priority int `json:"priority"`
|
||||
@@ -734,12 +734,10 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
_ = groupID // TODO: implement actual stats
|
||||
}
|
||||
|
||||
// GetUsageSummary returns today's and cumulative cost for all groups.
|
||||
// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai
|
||||
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
|
||||
// GET /api/v1/admin/groups/usage-summary
|
||||
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
userTZ := c.Query("timezone")
|
||||
now := timezone.NowInUserLocation(userTZ)
|
||||
todayStart := timezone.StartOfDayInUserLocation(now, userTZ)
|
||||
todayStart := service.GroupUsageTodayStart(time.Now())
|
||||
|
||||
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
|
||||
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
|
||||
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
|
||||
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return c.ShouldBindJSON(target)
|
||||
}
|
||||
|
||||
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||
allowed := []string{
|
||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "composite",
|
||||
}
|
||||
for _, platform := range allowed {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
var req CreateGroupRequest
|
||||
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
|
||||
"platform %q 应通过 CreateGroupRequest 校验", platform)
|
||||
require.Equal(t, platform, req.Platform)
|
||||
})
|
||||
t.Run("update_"+platform, func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
body := fmt.Sprintf(`{"platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
|
||||
"platform %q 应通过 UpdateGroupRequest 校验", platform)
|
||||
require.Equal(t, platform, req.Platform)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
||||
invalid := []string{
|
||||
"moonshot", // 厂商别名,不是平台标识
|
||||
"Kimi", // 大小写敏感
|
||||
"openai ", // 尾随空格
|
||||
"glm",
|
||||
"bogus",
|
||||
}
|
||||
for _, platform := range invalid {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
var req CreateGroupRequest
|
||||
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
|
||||
require.Error(t, bindGroupPlatformJSON(t, &req, body),
|
||||
"platform %q 应被 CreateGroupRequest 拒绝", platform)
|
||||
})
|
||||
t.Run("update_"+platform, func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
body := fmt.Sprintf(`{"platform":%q}`, platform)
|
||||
require.Error(t, bindGroupPlatformJSON(t, &req, body),
|
||||
"platform %q 应被 UpdateGroupRequest 拒绝", platform)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
|
||||
var req CompositeRouteRequest
|
||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||
require.Equal(t, platform, req.TargetPlatform)
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,6 @@ type openAIAccountStateRecoverer interface {
|
||||
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
|
||||
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
|
||||
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
|
||||
)
|
||||
|
||||
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
|
||||
// (non-refundable) reset credit has already been consumed upstream. The whole
|
||||
// request must stay comfortably inside the panel HTTP client timeout, otherwise
|
||||
@@ -493,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
service.NotifyOpenAIAutoResetCredit(accountID)
|
||||
response.Success(c, usage)
|
||||
}
|
||||
|
||||
@@ -523,6 +518,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
|
||||
return
|
||||
}
|
||||
service.NotifyOpenAIAutoResetCredit(accountID)
|
||||
|
||||
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
|
||||
// A failed snapshot write leaves the previous cache intact — report it as a
|
||||
@@ -600,54 +596,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
|
||||
defer cancelPost()
|
||||
|
||||
// Step 1 — unblocking the account is the whole point of consuming a credit
|
||||
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
|
||||
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
|
||||
if h.rateLimitService == nil {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
postResult := service.RunOpenAIQuotaResetPostProcess(
|
||||
postCtx,
|
||||
accountID,
|
||||
h.quotaService,
|
||||
h.rateLimitService,
|
||||
h.adminService.GetAccount,
|
||||
)
|
||||
resetResponse.Quota = postResult.Quota
|
||||
resetResponse.CacheRefreshed = postResult.CacheRefreshed
|
||||
resetResponse.AccountStateRecovered = postResult.AccountStateRecovered
|
||||
resetResponse.WarningCode = postResult.WarningCode
|
||||
if postResult.Account != nil {
|
||||
resetResponse.Account = dto.AccountFromService(postResult.Account)
|
||||
}
|
||||
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
|
||||
InvalidateToken: true,
|
||||
}); err != nil {
|
||||
// Recovery failures are almost always storage-level; the remaining steps
|
||||
// share that dependency, so stop here instead of compounding the failure.
|
||||
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.AccountStateRecovered = true
|
||||
|
||||
// Step 2 — refresh the reset-credit display cache. A failure here is reported
|
||||
// but must not hide the recovered account row produced by step 3.
|
||||
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
|
||||
switch {
|
||||
case usageErr != nil || usage == nil:
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
default:
|
||||
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
} else {
|
||||
resetResponse.Quota = usage
|
||||
resetResponse.CacheRefreshed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — hand back the post-recovery account row so the list drops the
|
||||
// stale rate-limit badge without waiting for the next poll.
|
||||
account, err := h.adminService.GetAccount(postCtx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
|
||||
if resetResponse.WarningCode == "" {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
|
||||
}
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.Account = dto.AccountFromService(account)
|
||||
response.Success(c, resetResponse)
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -237,7 +237,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, adminService.calls)
|
||||
@@ -260,7 +260,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -285,7 +285,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -307,7 +307,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
@@ -331,7 +331,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T)
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
}
|
||||
@@ -435,7 +435,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const pluginUISessionTTL = 30 * time.Minute
|
||||
|
||||
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
|
||||
type PluginHandler struct {
|
||||
manager *service.PluginManager
|
||||
}
|
||||
|
||||
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
|
||||
return &PluginHandler{manager: manager}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) List(c *gin.Context) {
|
||||
plugins, err := h.manager.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugins)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Get(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Upload(c *gin.Context) {
|
||||
maxBytes := h.manager.MaxUploadBytes()
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
|
||||
file, header, err := c.Request.FormFile("plugin")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
|
||||
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
|
||||
return
|
||||
}
|
||||
var installedBy *int64
|
||||
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||
userID := subject.UserID
|
||||
installedBy = &userID
|
||||
}
|
||||
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, plugin)
|
||||
}
|
||||
|
||||
type pluginEnableRequest struct {
|
||||
AcceptUntested bool `json:"accept_untested"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Enable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request := pluginEnableRequest{RolloutPercent: 100}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "启用参数无效")
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Disable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Disable(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Delete(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "插件已卸载"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) GetConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) SaveConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
response.BadRequest(c, "插件配置必须是有效 JSON")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "插件配置无法序列化")
|
||||
return
|
||||
}
|
||||
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Test(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.manager.Test(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) CreateUISession(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
bridgeToken, err := randomPluginToken()
|
||||
if err != nil {
|
||||
response.InternalError(c, "创建插件 UI Bridge 失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
|
||||
"bridge_token": bridgeToken,
|
||||
"ui_bridge_version": 1,
|
||||
"expires_at": expires,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
|
||||
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
pluginID, err := h.manager.ResolveUIAssetToken(token)
|
||||
if err != nil {
|
||||
c.Status(http.StatusGone)
|
||||
return
|
||||
}
|
||||
relative := strings.TrimPrefix(c.Param("path"), "/")
|
||||
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
|
||||
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
|
||||
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func pluginIDParam(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "插件 ID 无效")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func randomPluginToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorMode: settings.ChannelMonitorMode,
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
|
||||
GrokDefaultTextModel: settings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
|
||||
@@ -381,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -332,6 +332,7 @@ type UpdateSettingsRequest struct {
|
||||
ChannelMonitorMode *string `json:"channel_monitor_mode"`
|
||||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
|
||||
|
||||
// Grok model mapping policy
|
||||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||||
@@ -346,6 +347,9 @@ type UpdateSettingsRequest struct {
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||||
|
||||
// Plugin management menu visibility switch; plugin runtime is unaffected.
|
||||
PluginManagementEnabled *bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -437,7 +441,7 @@ func buildSettingKeyByJSONName() map[string]string {
|
||||
out := make(map[string]string, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
@@ -1889,6 +1893,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ChannelMonitorHideThroughput
|
||||
}(),
|
||||
ChannelMonitorShowQuota: func() bool {
|
||||
if req.ChannelMonitorShowQuota != nil {
|
||||
return *req.ChannelMonitorShowQuota
|
||||
}
|
||||
return previousSettings.ChannelMonitorShowQuota
|
||||
}(),
|
||||
GrokDefaultTextModel: func() string {
|
||||
if req.GrokDefaultTextModel != nil {
|
||||
return *req.GrokDefaultTextModel
|
||||
@@ -1931,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ModelPlazaDescription
|
||||
}(),
|
||||
PluginManagementEnabled: func() bool {
|
||||
if req.PluginManagementEnabled != nil {
|
||||
return *req.PluginManagementEnabled
|
||||
}
|
||||
return previousSettings.PluginManagementEnabled
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
@@ -2342,6 +2358,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
|
||||
|
||||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||||
@@ -2349,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
PluginManagementEnabled: updatedSettings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -112,12 +112,13 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
if len(repo.upsertCalls) != 1 {
|
||||
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
|
||||
}
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
|
||||
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
|
||||
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
|
||||
}
|
||||
// 缓存失效:按全部允许平台统一失效。
|
||||
if len(cache.deleteCalls) != 5 {
|
||||
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
|
||||
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
|
||||
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ec y: %w", err)
|
||||
}
|
||||
if !curve.IsOnCurve(x, y) {
|
||||
if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移
|
||||
return nil, errors.New("ec point is not on curve")
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty)
|
||||
}
|
||||
|
||||
@@ -284,13 +284,13 @@ func toUserSupportedModels(
|
||||
return out
|
||||
}
|
||||
|
||||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
if p == nil {
|
||||
// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。
|
||||
func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
|
||||
for _, iv := range p.Intervals {
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
@@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
}
|
||||
|
||||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := toUserPricingIntervals(p.Intervals)
|
||||
if intervals == nil {
|
||||
// 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。
|
||||
intervals = []userPricingIntervalDTO{}
|
||||
}
|
||||
billingMode := string(p.BillingMode)
|
||||
if billingMode == "" {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -39,6 +40,15 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
|
||||
return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1
|
||||
}
|
||||
|
||||
// quotaVisible 返回用户端是否展示配额/余额快照(channel_monitor_show_quota,
|
||||
// fail-closed:未配置/非 "true" 一律视为关闭)。settingService 为 nil 时 fail-closed。
|
||||
func (h *ChannelMonitorUserHandler) quotaVisible(c *gin.Context) bool {
|
||||
if h.settingService == nil {
|
||||
return false
|
||||
}
|
||||
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).ShowQuota
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
type channelMonitorUserListItem struct {
|
||||
@@ -53,6 +63,9 @@ type channelMonitorUserListItem struct {
|
||||
Availability7d float64 `json:"availability_7d"`
|
||||
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
|
||||
Timeline []channelMonitorUserTimelinePoint `json:"timeline"`
|
||||
// LatestQuota 主模型最近配额快照;channel_monitor_show_quota=false 时
|
||||
// 由 userMonitorViewToItem 的调用方传入 false 剥离(服务端脱敏,非仅前端隐藏)。
|
||||
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
|
||||
}
|
||||
|
||||
// channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。
|
||||
@@ -82,7 +95,7 @@ type channelMonitorUserModelStat struct {
|
||||
AvgLatency7dMs *int `json:"avg_latency_7d_ms"`
|
||||
}
|
||||
|
||||
func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem {
|
||||
func userMonitorViewToItem(v *service.UserMonitorView, includeQuota bool) channelMonitorUserListItem {
|
||||
extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels))
|
||||
for _, e := range v.ExtraModels {
|
||||
extras = append(extras, dto.ChannelMonitorExtraModelStatus{
|
||||
@@ -100,7 +113,7 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
|
||||
CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return channelMonitorUserListItem{
|
||||
item := channelMonitorUserListItem{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
Provider: v.Provider,
|
||||
@@ -113,6 +126,10 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
|
||||
ExtraModels: extras,
|
||||
Timeline: timeline,
|
||||
}
|
||||
if includeQuota {
|
||||
item.LatestQuota = v.LatestQuota
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse {
|
||||
@@ -150,9 +167,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
includeQuota := h.quotaVisible(c)
|
||||
items := make([]channelMonitorUserListItem, 0, len(views))
|
||||
for _, v := range views {
|
||||
items = append(items, userMonitorViewToItem(v))
|
||||
items = append(items, userMonitorViewToItem(v, includeQuota))
|
||||
}
|
||||
response.Success(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
@@ -22,18 +22,43 @@ func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
|
||||
require.Equal(t, service.PlatformOpenAI, platform)
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) {
|
||||
func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, path := range []string{"/v1/messages", "/v1/chat/completions"} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", path, nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||
providers := []struct {
|
||||
model string
|
||||
platform string
|
||||
}{
|
||||
{model: "grok-4.3", platform: service.PlatformGrok},
|
||||
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
|
||||
{model: "k3", platform: service.PlatformKimi},
|
||||
{model: "glm-5.2", platform: service.PlatformZhipu},
|
||||
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
|
||||
}
|
||||
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
|
||||
for _, provider := range providers {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", path, nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||
|
||||
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, "grok-4.3"), "path=%s", path)
|
||||
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
|
||||
require.True(t, ok, "path=%s", path)
|
||||
require.Equal(t, service.PlatformGrok, platform, "path=%s", path)
|
||||
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model)
|
||||
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
|
||||
require.True(t, ok, "path=%s model=%s", path, provider.model)
|
||||
require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换,
|
||||
// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。
|
||||
func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) {
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
|
||||
for _, platform := range []string{
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
|
||||
service.PlatformAnthropic, service.PlatformGemini,
|
||||
} {
|
||||
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) {
|
||||
|
||||
// Fields that legitimately live only on the DTO. Keep tiny; document each.
|
||||
dtoOnlyFields := map[string]string{
|
||||
// sora_client_enabled is an upstream-only field the fork does not surface.
|
||||
"sora_client_enabled": "upstream-only field, not used on this fork",
|
||||
// force_email_on_third_party_signup lives on the DTO but is not injected via SSR.
|
||||
"force_email_on_third_party_signup": "auth-source default, not a feature flag",
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ type SystemSettings struct {
|
||||
ChannelMonitorMode string `json:"channel_monitor_mode"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
@@ -315,9 +316,10 @@ type SystemSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// 风控中心功能开关
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
@@ -397,7 +399,6 @@ type PublicSettings struct {
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
@@ -414,11 +415,13 @@ type PublicSettings struct {
|
||||
ChannelMonitorMode string `json:"channel_monitor_mode"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
|
||||
@@ -15,20 +15,21 @@ import (
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
EndpointMessages = "/v1/messages"
|
||||
EndpointChatCompletions = "/v1/chat/completions"
|
||||
EndpointEmbeddings = "/v1/embeddings"
|
||||
EndpointAlphaSearch = "/v1/alpha/search"
|
||||
EndpointResponses = "/v1/responses"
|
||||
EndpointResponsesCompact = "/v1/responses/compact"
|
||||
EndpointImagesGenerations = "/v1/images/generations"
|
||||
EndpointImagesEdits = "/v1/images/edits"
|
||||
EndpointImageTasks = "/v1/images/tasks"
|
||||
EndpointVideosGenerations = "/v1/videos/generations"
|
||||
EndpointVideosEdits = "/v1/videos/edits"
|
||||
EndpointVideosExtensions = "/v1/videos/extensions"
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
EndpointMessages = "/v1/messages"
|
||||
EndpointChatCompletions = "/v1/chat/completions"
|
||||
EndpointEmbeddings = "/v1/embeddings"
|
||||
EndpointAlphaSearch = "/v1/alpha/search"
|
||||
EndpointResponses = "/v1/responses"
|
||||
EndpointResponsesCompact = "/v1/responses/compact"
|
||||
EndpointResponsesInputTokens = "/v1/responses/input_tokens"
|
||||
EndpointImagesGenerations = "/v1/images/generations"
|
||||
EndpointImagesEdits = "/v1/images/edits"
|
||||
EndpointImageTasks = "/v1/images/tasks"
|
||||
EndpointVideosGenerations = "/v1/videos/generations"
|
||||
EndpointVideosEdits = "/v1/videos/edits"
|
||||
EndpointVideosExtensions = "/v1/videos/extensions"
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
)
|
||||
|
||||
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
|
||||
@@ -80,6 +81,8 @@ const (
|
||||
func NormalizeInboundEndpoint(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
switch {
|
||||
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
|
||||
return EndpointResponsesInputTokens
|
||||
case strings.Contains(path, EndpointEmbeddings):
|
||||
return EndpointEmbeddings
|
||||
case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
|
||||
@@ -113,6 +116,15 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func isResponsesInputTokensAliasPath(path string) bool {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
return isBareOrSubpathOf(trimmed, "/responses/input_tokens") ||
|
||||
isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens")
|
||||
}
|
||||
|
||||
// isResponsesCompactAliasPath reports whether path is the bare/alias
|
||||
// "compact" client endpoint — i.e. it is rooted at "/responses/compact"
|
||||
// or "/backend-api/codex/responses/compact" (bare routes that serve
|
||||
@@ -185,7 +197,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||
|
||||
switch platform {
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
|
||||
return inbound
|
||||
}
|
||||
// OpenAI forwards everything to the Responses API.
|
||||
@@ -300,6 +312,14 @@ func GetInboundEndpoint(c *gin.Context) string {
|
||||
// and the account platform. Handlers call this after scheduling an
|
||||
// account, passing account.Platform.
|
||||
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
||||
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
|
||||
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
|
||||
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
|
||||
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
|
||||
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
if c != nil {
|
||||
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
|
||||
if endpoint, ok := value.(string); ok && endpoint != "" {
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
{"/v1/embeddings", EndpointEmbeddings},
|
||||
{"/v1/alpha/search", EndpointAlphaSearch},
|
||||
{"/v1/responses", EndpointResponses},
|
||||
{"/v1/responses/input_tokens", EndpointResponsesInputTokens},
|
||||
{"/v1/responses/compact", EndpointResponsesCompact},
|
||||
{"/v1/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/v1/images/generations", EndpointImagesGenerations},
|
||||
@@ -50,6 +51,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
|
||||
// Bare top-level alias route "/responses" — root vs. compact.
|
||||
{"/responses", EndpointResponses},
|
||||
{"/responses/input_tokens", EndpointResponsesInputTokens},
|
||||
{"/responses/compact", EndpointResponsesCompact},
|
||||
{"/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/alpha/search", EndpointAlphaSearch},
|
||||
@@ -57,6 +59,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
|
||||
// Bare Codex direct alias route — root vs. compact.
|
||||
{"/backend-api/codex/responses", EndpointResponses},
|
||||
{"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens},
|
||||
{"/backend-api/codex/responses/compact", EndpointResponsesCompact},
|
||||
{"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/backend-api/codex/alpha/search", EndpointAlphaSearch},
|
||||
@@ -100,6 +103,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||
|
||||
// OpenAI — root Responses.
|
||||
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
|
||||
{"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens},
|
||||
|
||||
// OpenAI — compact, raw path carries the derivable "/compact"
|
||||
// (or nested) suffix, which must be preserved on the upstream
|
||||
@@ -180,6 +184,16 @@ func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
|
||||
require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
|
||||
}
|
||||
|
||||
func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
|
||||
c.Set(ctxKeyInboundEndpoint, EndpointResponses)
|
||||
|
||||
service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
|
||||
require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
|
||||
}
|
||||
|
||||
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -56,7 +56,13 @@ const (
|
||||
const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
|
||||
|
||||
func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration {
|
||||
if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 {
|
||||
if failoverErr == nil {
|
||||
return sameAccountRetryDelay
|
||||
}
|
||||
if failoverErr.SameAccountRetryDelay > 0 {
|
||||
return failoverErr.SameAccountRetryDelay
|
||||
}
|
||||
if !failoverErr.RequestScopedTransient || retryCount <= 1 {
|
||||
return sameAccountRetryDelay
|
||||
}
|
||||
|
||||
@@ -70,6 +76,51 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC
|
||||
return delay
|
||||
}
|
||||
|
||||
func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool {
|
||||
if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
|
||||
return false
|
||||
}
|
||||
if !sameAccountRetryDeadlineAllows(failoverErr) {
|
||||
return false
|
||||
}
|
||||
// Error-specific caps (Grok capacity/stream-idle) remain hard limits even
|
||||
// when the error also carries a freshly reconstructed deadline.
|
||||
if failoverErr.SameAccountRetryMax > 0 {
|
||||
if retryLimit <= 0 {
|
||||
return false
|
||||
}
|
||||
if failoverErr.SameAccountRetryMax < retryLimit {
|
||||
retryLimit = failoverErr.SameAccountRetryMax
|
||||
}
|
||||
return retryCount < retryLimit
|
||||
}
|
||||
// OAuth 429 explicitly opts into a deadline window. It is intentionally not
|
||||
// bounded by the ordinary/default pool retry count.
|
||||
if !failoverErr.SameAccountRetryDeadline.IsZero() {
|
||||
return true
|
||||
}
|
||||
return retryLimit > 0 && retryCount < retryLimit
|
||||
}
|
||||
|
||||
// sameAccountRetryDeadlineAllows prevents a retry from starting after the
|
||||
// service-provided same-account retry window has elapsed.
|
||||
func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool {
|
||||
return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline)
|
||||
}
|
||||
|
||||
// effectiveSameAccountRetryLimit applies an error-specific cap without
|
||||
// overriding an explicit account setting of zero (which disables retries).
|
||||
func effectiveSameAccountRetryLimit(failoverErr *service.UpstreamFailoverError, account *service.Account) int {
|
||||
if account == nil {
|
||||
return 0
|
||||
}
|
||||
limit := account.GetPoolModeRetryCount()
|
||||
if limit > 0 && failoverErr != nil && failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < limit {
|
||||
return failoverErr.SameAccountRetryMax
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// FailoverState 跨循环迭代共享的 failover 状态
|
||||
type FailoverState struct {
|
||||
SwitchCount int
|
||||
@@ -158,14 +209,15 @@ func (s *FailoverState) HandleFailoverError(
|
||||
}
|
||||
|
||||
// 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。
|
||||
sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit
|
||||
retryCount := s.SameAccountRetryCount[accountID]
|
||||
sameAccountRetry := sameAccountRetryAllowed(failoverErr, retryCount, retryLimit)
|
||||
if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) {
|
||||
s.ForceCacheBilling = true
|
||||
}
|
||||
|
||||
// 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。
|
||||
// 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。
|
||||
if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit {
|
||||
if sameAccountRetry {
|
||||
s.SameAccountRetryCount[accountID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID])
|
||||
logger.FromContext(ctx).Warn("gateway.failover_same_account_retry",
|
||||
|
||||
@@ -58,6 +58,60 @@ func TestSameAccountRetryDelayFor(t *testing.T) {
|
||||
t.Run("nil error keeps fixed delay", func(t *testing.T) {
|
||||
require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(nil, 10))
|
||||
})
|
||||
|
||||
t.Run("explicit oauth delay wins", func(t *testing.T) {
|
||||
err := &service.UpstreamFailoverError{SameAccountRetryDelay: 3 * time.Second}
|
||||
require.Equal(t, 3*time.Second, sameAccountRetryDelayFor(err, 1))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSameAccountRetryAllowedUsesDeadlineInsteadOfPoolCount(t *testing.T) {
|
||||
err := &service.UpstreamFailoverError{
|
||||
RetryableOnSameAccount: true,
|
||||
SameAccountRetryDeadline: time.Now().Add(time.Minute),
|
||||
}
|
||||
require.True(t, sameAccountRetryAllowed(err, 100, 0))
|
||||
require.True(t, sameAccountRetryAllowed(err, 100, maxSameAccountRetries))
|
||||
err.SameAccountRetryDeadline = time.Now().Add(-time.Second)
|
||||
require.False(t, sameAccountRetryAllowed(err, 0, 100))
|
||||
}
|
||||
|
||||
func TestSameAccountRetryAllowedRequiresOptInAndDefaultsToCountLimit(t *testing.T) {
|
||||
err := &service.UpstreamFailoverError{SameAccountRetryDeadline: time.Now().Add(time.Minute)}
|
||||
require.False(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries))
|
||||
|
||||
err.RetryableOnSameAccount = true
|
||||
err.SameAccountRetryDeadline = time.Time{}
|
||||
require.True(t, sameAccountRetryAllowed(err, maxSameAccountRetries-1, maxSameAccountRetries))
|
||||
require.False(t, sameAccountRetryAllowed(err, maxSameAccountRetries, maxSameAccountRetries))
|
||||
}
|
||||
|
||||
func TestSameAccountRetryAllowedHonorsErrorMaxBeforeDeadline(t *testing.T) {
|
||||
err := &service.UpstreamFailoverError{
|
||||
RetryableOnSameAccount: true,
|
||||
SameAccountRetryDeadline: time.Now().Add(time.Minute),
|
||||
SameAccountRetryMax: 1,
|
||||
}
|
||||
require.True(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries))
|
||||
require.False(t, sameAccountRetryAllowed(err, 1, maxSameAccountRetries))
|
||||
require.False(t, sameAccountRetryAllowed(err, 0, 0), "an explicit zero retry budget remains disabled")
|
||||
}
|
||||
|
||||
func TestSameAccountRetryDeadlineAllows(t *testing.T) {
|
||||
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{}))
|
||||
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
|
||||
SameAccountRetryDeadline: time.Now().Add(time.Second),
|
||||
}))
|
||||
require.False(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
|
||||
SameAccountRetryDeadline: time.Now().Add(-time.Second),
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEffectiveSameAccountRetryLimitHonorsErrorCapAndDisabledAccount(t *testing.T) {
|
||||
account := &service.Account{Type: service.AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true, "pool_mode_retry_count": float64(3)}}
|
||||
require.Equal(t, 1, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account))
|
||||
account.Credentials["pool_mode_retry_count"] = float64(0)
|
||||
require.Equal(t, 0, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -320,6 +374,22 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
|
||||
require.Zero(t, fs.SwitchCount)
|
||||
})
|
||||
|
||||
t.Run("OAuth deadline存在时不按普通计数切换", func(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, true)
|
||||
fs.SameAccountRetryCount[100] = maxSameAccountRetries
|
||||
err := newTestFailoverErr(http.StatusTooManyRequests, true, false)
|
||||
err.SameAccountRetryDeadline = time.Now().Add(time.Minute)
|
||||
err.SameAccountRetryDelay = time.Nanosecond
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
|
||||
require.False(t, fs.ForceCacheBilling)
|
||||
require.Zero(t, fs.SwitchCount)
|
||||
require.Equal(t, maxSameAccountRetries+1, fs.SameAccountRetryCount[100])
|
||||
require.Empty(t, mock.calls)
|
||||
})
|
||||
|
||||
t.Run("同账号重试耗尽并实际切换时设置ForceCacheBilling", func(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, true)
|
||||
|
||||
@@ -1140,6 +1140,79 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CodexModels returns the effective group model list using the manifest shape
|
||||
// expected by Codex custom providers. Official OpenAI groups continue to use
|
||||
// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved.
|
||||
func (h *GatewayHandler) CodexModels(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil || apiKey.Group == nil {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
|
||||
return
|
||||
}
|
||||
|
||||
forcedPlatform := ""
|
||||
if value, exists := middleware2.GetForcePlatformFromContext(c); exists {
|
||||
forcedPlatform = strings.TrimSpace(value)
|
||||
}
|
||||
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform)
|
||||
modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group)
|
||||
body, err := h.gatewayService.BuildCodexModelsManifestForGroup(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
forcedPlatform,
|
||||
modelIDs,
|
||||
)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
etag := service.CodexModelsManifestETag(body)
|
||||
c.Header("ETag", etag)
|
||||
if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string {
|
||||
if h == nil || h.gatewayService == nil || group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupID := &group.ID
|
||||
platform := strings.TrimSpace(platformOverride)
|
||||
if platform == "" {
|
||||
platform = group.Platform
|
||||
}
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(platform)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(
|
||||
customModelsListSource(platform, availableModels, fallbackModels),
|
||||
fallbackModels,
|
||||
group.ModelsListConfig.Models,
|
||||
)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
|
||||
if h == nil || h.gatewayService == nil {
|
||||
return nil
|
||||
@@ -1147,10 +1220,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]string, 0)
|
||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
if len(platformModels) == 0 {
|
||||
if _, ok := schedulablePlatforms[platform]; ok {
|
||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
|
||||
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
|
||||
platformModels = defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
}
|
||||
@@ -1338,9 +1413,26 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||
if normalizedClaudeModel != model {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == normalizedClaudeModel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultCodexModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformDeepseek:
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
default:
|
||||
return defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
@@ -1359,20 +1451,13 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAnthropic:
|
||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return mergeModelIDs(ids, nil)
|
||||
return claude.DefaultModelIDs()
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformComposite:
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
@@ -173,6 +174,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
@@ -384,6 +386,14 @@ func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *serv
|
||||
h.chatCompletionsErrorResponse(c, status, "server_error", message)
|
||||
return
|
||||
}
|
||||
if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" {
|
||||
status := lastErr.ClientStatusCode
|
||||
if status <= 0 {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
h.chatCompletionsErrorResponse(c, status, "server_error", lastErr.ClientMessage)
|
||||
return
|
||||
}
|
||||
statusCode := http.StatusBadGateway
|
||||
if lastErr != nil && lastErr.StatusCode > 0 {
|
||||
statusCode = lastErr.StatusCode
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
@@ -175,6 +176,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
@@ -361,25 +363,38 @@ func (h *GatewayHandler) responsesErrorResponse(c *gin.Context, status int, code
|
||||
|
||||
// handleResponsesFailoverExhausted writes a failover-exhausted error in Responses format.
|
||||
func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastErr *service.UpstreamFailoverError, streamStarted bool) {
|
||||
if streamStarted {
|
||||
return // Can't write error after stream started
|
||||
}
|
||||
if lastErr != nil {
|
||||
copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
|
||||
}
|
||||
if lastErr != nil && lastErr.IsCredentialFailure() {
|
||||
status, message := credentialFailoverClientResponse(lastErr)
|
||||
h.responsesErrorResponse(c, status, "server_error", message)
|
||||
return
|
||||
}
|
||||
statusCode := http.StatusBadGateway
|
||||
if lastErr != nil && lastErr.StatusCode > 0 {
|
||||
statusCode = lastErr.StatusCode
|
||||
}
|
||||
if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) {
|
||||
status, code, message := statusCode, "server_error", "All available accounts exhausted"
|
||||
if lastErr != nil && lastErr.IsCredentialFailure() {
|
||||
status, message = credentialFailoverClientResponse(lastErr)
|
||||
} else if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" {
|
||||
status = lastErr.ClientStatusCode
|
||||
if status <= 0 {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
message = lastErr.ClientMessage
|
||||
} else if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) {
|
||||
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
|
||||
h.responsesErrorResponse(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage())
|
||||
status, code, message = http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage()
|
||||
} else if lastErr != nil && statusCode == http.StatusTooManyRequests {
|
||||
status, code, message = http.StatusTooManyRequests, "rate_limit_error", "All available accounts are currently rate-limited. Please retry later."
|
||||
}
|
||||
if streamStarted {
|
||||
// A slot-wait heartbeat commits HTTP 200 before any upstream response.
|
||||
// In that case a terminal frame is still required; once any semantic or
|
||||
// official terminal bytes exist, preserve them without appending a second
|
||||
// generic response.failed.
|
||||
service.MarkOpsStreamError(c, code, message, status)
|
||||
if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
|
||||
writeResponsesFailedSSE(c, code, message)
|
||||
}
|
||||
return
|
||||
}
|
||||
h.responsesErrorResponse(c, statusCode, "server_error", "All available accounts exhausted")
|
||||
h.responsesErrorResponse(c, status, code, message)
|
||||
}
|
||||
@@ -16,6 +16,29 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const gatewayStreamHeartbeatBytesKey = "gateway_stream_heartbeat_bytes"
|
||||
|
||||
func recordGatewayStreamHeartbeat(c *gin.Context, written int) {
|
||||
if c == nil || written <= 0 {
|
||||
return
|
||||
}
|
||||
total, _ := c.Get(gatewayStreamHeartbeatBytesKey)
|
||||
bytes, _ := total.(int)
|
||||
c.Set(gatewayStreamHeartbeatBytesKey, bytes+written)
|
||||
}
|
||||
|
||||
func gatewayStreamHasOnlyHeartbeats(c *gin.Context) bool {
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := c.Get(gatewayStreamHeartbeatBytesKey)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
heartbeatBytes, _ := value.(int)
|
||||
return heartbeatBytes > 0 && c.Writer.Size() == heartbeatBytes
|
||||
}
|
||||
|
||||
// claudeCodeValidator is a singleton validator for Claude Code client detection
|
||||
var claudeCodeValidator = service.NewClaudeCodeValidator()
|
||||
|
||||
@@ -396,9 +419,11 @@ func (h *ConcurrencyHelper) waitForSlotWithPingTimeout(c *gin.Context, slotType
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
*streamStarted = true
|
||||
}
|
||||
if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil {
|
||||
written, err := fmt.Fprint(c.Writer, string(h.pingFormat))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recordGatewayStreamHeartbeat(c, written)
|
||||
flusher.Flush()
|
||||
|
||||
case <-timer.C:
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,6 +25,22 @@ type gatewayModelsResponseForTest struct {
|
||||
Data []gatewayModelItemForTest `json:"data"`
|
||||
}
|
||||
|
||||
type codexModelsResponseForTest struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type codexReasoningLevelForTest struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
|
||||
type gatewayModelItemForTest struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -51,6 +68,10 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
|
||||
return s.ListSchedulableByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
@@ -69,6 +90,247 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
require.Contains(t, compositeIDs, antigravityIDs[0])
|
||||
}
|
||||
|
||||
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
|
||||
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
|
||||
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
|
||||
require.Contains(t, anthropicIDs, "claude-opus-4-6")
|
||||
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
|
||||
|
||||
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
||||
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
efforts []string
|
||||
modalities []string
|
||||
}{
|
||||
{
|
||||
name: "Grok",
|
||||
platform: service.PlatformGrok,
|
||||
model: "grok-4.6",
|
||||
efforts: []string{"low", "medium", "high", "xhigh"},
|
||||
modalities: []string{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
platform: service.PlatformDeepseek,
|
||||
model: "deepseek-v4-pro",
|
||||
efforts: []string{"low", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
{
|
||||
name: "provider-qualified Claude",
|
||||
platform: service.PlatformAnthropic,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
efforts: []string{"low", "medium", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
}
|
||||
|
||||
for index, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(100 + index)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: tt.platform,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{tt.model: tt.model},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: tt.platform},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Models, 1)
|
||||
require.Equal(t, tt.model, got.Models[0].Slug)
|
||||
require.NotEmpty(t, got.Models[0].ModelMessages)
|
||||
require.NotEmpty(t, got.Models[0].TruncationPolicy)
|
||||
require.NotNil(t, got.Models[0].AvailabilityNUX)
|
||||
require.NotNil(t, got.Models[0].Upgrade)
|
||||
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
|
||||
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: Composite manifests aggregate only administrator-configured models.
|
||||
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 120
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 3,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 122
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
firstContext, _ := gin.CreateTestContext(first)
|
||||
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(firstContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(second)
|
||||
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
|
||||
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(secondContext)
|
||||
|
||||
require.Equal(t, http.StatusNotModified, second.Code)
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 121
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"grok-4.6"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func codexModelSlugsForTest(models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
}) []string {
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -192,6 +454,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
|
||||
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(64)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := codexModelSlugsForTest(got.Models)
|
||||
require.Contains(t, slugs, "claude-opus-4-6")
|
||||
require.NotContains(t, slugs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
|
||||
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(65)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
ids := modelIDsForTest(got.Data)
|
||||
require.Contains(t, ids, "claude-opus-4-6")
|
||||
require.Contains(t, ids, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -321,6 +639,27 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Platform: service.PlatformKimi,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"kimi-custom": "kimi-upstream"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Platform: service.PlatformZhipu,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"glm-custom": "glm-upstream"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 6,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -335,7 +674,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5"},
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -346,7 +685,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5"}, modelIDsForTest(got.Data))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
|
||||
@@ -385,6 +724,139 @@ func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t
|
||||
require.NotContains(t, ids, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把
|
||||
// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。
|
||||
func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(35)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{ID: 1, Platform: service.PlatformOpenAI},
|
||||
{ID: 2, Platform: service.PlatformKimi},
|
||||
{ID: 3, Platform: service.PlatformZhipu},
|
||||
{ID: 4, Platform: service.PlatformDeepseek},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
|
||||
ids := modelIDsForTest(got.Data)
|
||||
require.Contains(t, ids, "gpt-5.5")
|
||||
require.NotContains(t, ids, "claude-sonnet-4-6")
|
||||
}
|
||||
|
||||
// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的
|
||||
// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。
|
||||
func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) {
|
||||
want := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
want = append(want, model.ID)
|
||||
}
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 130
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Contains(t, slugs, "deepseek-v4-pro")
|
||||
require.Contains(t, slugs, "deepseek-v4-flash")
|
||||
require.NotContains(t, slugs, "claude-sonnet-4-6")
|
||||
require.NotContains(t, slugs, "claude-opus-4-6")
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 131
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"foo-*": "deepseek-v4-pro",
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -569,6 +569,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
|
||||
var longContextThreshold int
|
||||
var longContextMultiplier float64
|
||||
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
|
||||
longContextThreshold = rule.Threshold
|
||||
longContextMultiplier = rule.Multiplier
|
||||
}
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
|
||||
Result: result,
|
||||
@@ -583,8 +590,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
LongContextThreshold: 200000, // Gemini 200K 阈值
|
||||
LongContextMultiplier: 2.0, // 超出部分双倍计费
|
||||
LongContextThreshold: longContextThreshold,
|
||||
LongContextMultiplier: longContextMultiplier,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
|
||||
@@ -44,39 +44,84 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
"",
|
||||
"grok-4.5",
|
||||
nil,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
// Grok only advertises chat_completions + media capabilities on HEAD.
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
)
|
||||
if err != nil || selection == nil || selection.Account == nil {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
||||
return
|
||||
}
|
||||
|
||||
var streamStarted bool
|
||||
reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
|
||||
release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog)
|
||||
if slotStatus != openAISlotAcquireOK {
|
||||
model := c.Query("model")
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "grok-voice-latest"
|
||||
}
|
||||
// Keep the HTTP response uncommitted while selecting and probing an account.
|
||||
// Realtime is not an HTTP streaming response; using reqStream=true here would
|
||||
// let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
|
||||
failed := map[int64]struct{}{}
|
||||
var selection *service.AccountSelectionResult
|
||||
var release func()
|
||||
var token string
|
||||
var upstream *service.GrokRealtimeUpstream
|
||||
var candidateSeen bool
|
||||
for attempts := 0; attempts < 4; attempts++ {
|
||||
// Realtime's voice model is not a text-model capability. Passing a
|
||||
// concrete text model here would reject accounts mapped only to an
|
||||
// older/default text model before the upstream handshake can decide.
|
||||
// An empty requested model keeps account selection capability-based;
|
||||
// the actual voice model remains in the upstream WS query below.
|
||||
candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(), apiKey.GroupID, "", "", "", failed,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false, false, false, service.PlatformGrok,
|
||||
)
|
||||
if selectErr != nil || candidate == nil || candidate.Account == nil {
|
||||
break
|
||||
}
|
||||
candidateSeen = true
|
||||
account := candidate.Account
|
||||
var streamStarted bool
|
||||
var slotStatus openAISlotAcquireResult
|
||||
release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog)
|
||||
if slotStatus != openAISlotAcquireOK {
|
||||
if slotStatus == openAISlotAcquireFailed {
|
||||
return
|
||||
}
|
||||
failed[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
var credErr error
|
||||
token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account)
|
||||
if credErr != nil {
|
||||
release()
|
||||
release = nil
|
||||
failed[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), service.DefaultGrokRealtimeDialTimeout)
|
||||
candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model)
|
||||
cancelProbe()
|
||||
if openErr != nil {
|
||||
reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr))
|
||||
statusCode := http.StatusBadGateway
|
||||
var dialErr *service.GrokRealtimeDialError
|
||||
if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 {
|
||||
statusCode = dialErr.StatusCode
|
||||
}
|
||||
h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error()))
|
||||
release()
|
||||
release = nil
|
||||
failed[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
selection, upstream = candidate, candidateUpstream
|
||||
break
|
||||
}
|
||||
if selection == nil || selection.Account == nil || release == nil || upstream == nil {
|
||||
if !candidateSeen {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
||||
} else {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable")
|
||||
return
|
||||
}
|
||||
defer func() { _ = upstream.Close() }()
|
||||
|
||||
conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
||||
if err != nil {
|
||||
@@ -84,12 +129,8 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
|
||||
model := c.Query("model")
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "grok-voice-latest"
|
||||
}
|
||||
started := time.Now()
|
||||
audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model)
|
||||
audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream)
|
||||
elapsed := time.Since(started)
|
||||
if proxyErr != nil {
|
||||
reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
|
||||
|
||||
@@ -346,7 +346,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil)
|
||||
}
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
@@ -361,19 +361,21 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
retryLimit := effectiveSameAccountRetryLimit(failoverErr, account)
|
||||
if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("grok_media.pool_mode_same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
zap.Duration("retry_delay", retryDelay),
|
||||
)
|
||||
select {
|
||||
case <-requestCtx.Done():
|
||||
return
|
||||
case <-time.After(sameAccountRetryDelay):
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -398,7 +400,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil)
|
||||
if !service.IsResponseCommitted(c) && c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -409,7 +411,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, result), true, nil)
|
||||
if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" {
|
||||
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
|
||||
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
|
||||
|
||||
@@ -19,6 +19,7 @@ type AdminHandlers struct {
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
GrokOAuth *admin.GrokOAuthHandler
|
||||
CNProvider *admin.CNProviderHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
@@ -30,6 +31,7 @@ type AdminHandlers struct {
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
Plugin *admin.PluginHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Channel *admin.ChannelHandler
|
||||
|
||||
@@ -17,39 +17,60 @@ import (
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
|
||||
type ModelPlazaHandler struct {
|
||||
channelService *service.ChannelService
|
||||
plazaService *service.ModelPlazaService
|
||||
apiKeyService *service.APIKeyService
|
||||
settingService *service.SettingService
|
||||
}
|
||||
|
||||
// NewModelPlazaHandler 创建模型广场 handler。
|
||||
func NewModelPlazaHandler(
|
||||
channelService *service.ChannelService,
|
||||
plazaService *service.ModelPlazaService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
settingService *service.SettingService,
|
||||
) *ModelPlazaHandler {
|
||||
return &ModelPlazaHandler{
|
||||
channelService: channelService,
|
||||
plazaService: plazaService,
|
||||
apiKeyService: apiKeyService,
|
||||
settingService: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
|
||||
// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
|
||||
type modelPlazaOfficialPricing struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
// Intervals 官方长上下文阶梯,仅多档模型给出。
|
||||
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
|
||||
// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。
|
||||
type modelPlazaTimePricingPeriod struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
}
|
||||
|
||||
// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。
|
||||
// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
|
||||
type modelPlazaTimePricing struct {
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only,omitempty"`
|
||||
Periods []modelPlazaTimePricingPeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
|
||||
type modelPlazaModel struct {
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Pricing *userSupportedModelPricing `json:"pricing"`
|
||||
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
|
||||
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
|
||||
LongContextBasis string `json:"long_context_basis,omitempty"`
|
||||
// TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。
|
||||
TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"`
|
||||
}
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
@@ -68,9 +89,11 @@ type modelPlazaGroup struct {
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
|
||||
// 不取分组/用户专属倍率。
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
|
||||
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
}
|
||||
|
||||
// modelPlazaResponse 广场页响应。
|
||||
@@ -98,7 +121,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
|
||||
groups, err := h.plazaService.ListGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -161,27 +184,30 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
for i := range g.Models {
|
||||
m := &g.Models[i]
|
||||
models = append(models, modelPlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
LongContextBasis: string(m.LongContextBasis),
|
||||
TimePricing: toModelPlazaTimePricing(m.TimePricing),
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
Models: models,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
||||
Models: models,
|
||||
}
|
||||
if rate, ok := userRates[g.ID]; ok {
|
||||
dto.UserRateMultiplier = &rate
|
||||
@@ -189,6 +215,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
return dto
|
||||
}
|
||||
|
||||
// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。
|
||||
func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing {
|
||||
if p == nil || len(p.Periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods))
|
||||
for _, period := range p.Periods {
|
||||
periods = append(periods, modelPlazaTimePricingPeriod{
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods}
|
||||
}
|
||||
|
||||
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
|
||||
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
|
||||
if p == nil {
|
||||
@@ -200,5 +242,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
Intervals: toUserPricingIntervals(p.Intervals),
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
"id", "name", "description", "platform", "subscription_type",
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
@@ -109,6 +109,12 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
require.Contains(t, official, "cache_read_price")
|
||||
_, has1h := official["cache_write_1h_price"]
|
||||
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
|
||||
_, hasOfficialIntervals := official["intervals"]
|
||||
require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty")
|
||||
_, hasBasis := model["long_context_basis"]
|
||||
require.False(t, hasBasis, "单档模型不输出 long_context_basis")
|
||||
_, hasTimePricing := model["time_pricing"]
|
||||
require.False(t, hasTimePricing, "无分时时不输出 time_pricing")
|
||||
|
||||
// 无专属倍率:user_rate_multiplier 整个字段省略
|
||||
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
|
||||
@@ -124,4 +130,94 @@ func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
|
||||
require.Nil(t, toModelPlazaOfficialPricing(nil))
|
||||
}
|
||||
|
||||
func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) {
|
||||
maxTokens := 272000
|
||||
g := service.PlazaGroup{
|
||||
ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1,
|
||||
LongContextPricingEnabled: true,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "gpt-5.4",
|
||||
Platform: "openai",
|
||||
Pricing: &service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
InputPrice: testPtr(2.5e-6),
|
||||
Intervals: []service.PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
|
||||
{MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
|
||||
},
|
||||
},
|
||||
OfficialPricing: &service.PlazaOfficialPricing{
|
||||
InputPrice: testPtr(2.5e-6),
|
||||
Intervals: []service.PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
|
||||
{MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
|
||||
},
|
||||
},
|
||||
LongContextBasis: service.ContextPricingBasisWholeRequest,
|
||||
}},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.Equal(t, true, decoded["long_context_pricing_enabled"])
|
||||
|
||||
model := decoded["models"].([]any)[0].(map[string]any)
|
||||
require.Equal(t, "whole_request", model["long_context_basis"])
|
||||
|
||||
pricing := model["pricing"].(map[string]any)
|
||||
paidTiers := pricing["intervals"].([]any)
|
||||
require.Len(t, paidTiers, 2)
|
||||
require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"])
|
||||
|
||||
official := model["official_pricing"].(map[string]any)
|
||||
officialTiers := official["intervals"].([]any)
|
||||
require.Len(t, officialTiers, 2)
|
||||
first := officialTiers[0].(map[string]any)
|
||||
require.Equal(t, "≤272K", first["tier_label"])
|
||||
require.InDelta(t, 272000, first["max_tokens"].(float64), 0)
|
||||
require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)")
|
||||
}
|
||||
|
||||
func testPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
|
||||
g := service.PlazaGroup{
|
||||
ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "deepseek-chat",
|
||||
Platform: "deepseek",
|
||||
Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)},
|
||||
TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
}},
|
||||
}, {
|
||||
Name: "deepseek-reasoner",
|
||||
Platform: "deepseek",
|
||||
Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.56e-6)},
|
||||
TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []service.TimePricingPeriod{
|
||||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
model := decoded["models"].([]any)[0].(map[string]any)
|
||||
tp := model["time_pricing"].(map[string]any)
|
||||
require.Equal(t, "Asia/Shanghai", tp["timezone"])
|
||||
_, hasWeekdaysOnly := tp["weekdays_only"]
|
||||
require.False(t, hasWeekdaysOnly, "未开启仅工作日时字段省略")
|
||||
periods := tp["periods"].([]any)
|
||||
require.Len(t, periods, 1)
|
||||
first := periods[0].(map[string]any)
|
||||
require.Equal(t, "00:30", first["start_time"])
|
||||
require.Equal(t, "08:30", first["end_time"])
|
||||
require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12)
|
||||
|
||||
weekdaysModel := decoded["models"].([]any)[1].(map[string]any)
|
||||
weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
|
||||
require.Equal(t, true, weekdaysTP["weekdays_only"])
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -32,6 +34,29 @@ type noAccountErrorClassification struct {
|
||||
ModelNotFound bool // true when this is a 404 model_not_found classification
|
||||
}
|
||||
|
||||
var selectionModelRateLimitedPattern = regexp.MustCompile(`(?:model_rate_limited|rate_limited)=(\d+)`)
|
||||
|
||||
// classifySelectionFailureError preserves the scheduler's compact reason when
|
||||
// every model-capable account is temporarily rate limited.
|
||||
func classifySelectionFailureError(err error, fallback noAccountErrorClassification) noAccountErrorClassification {
|
||||
if err == nil {
|
||||
return fallback
|
||||
}
|
||||
match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
|
||||
if len(match) != 2 {
|
||||
return fallback
|
||||
}
|
||||
count, parseErr := strconv.Atoi(match[1])
|
||||
if parseErr != nil || count <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return noAccountErrorClassification{
|
||||
Status: http.StatusTooManyRequests,
|
||||
ErrType: "rate_limit_error",
|
||||
Message: "All available accounts are currently rate-limited. Please retry later.",
|
||||
}
|
||||
}
|
||||
|
||||
// classifyNoAccountError decides between 404 model_not_found and 503
|
||||
// api_error for "no available accounts" failures.
|
||||
//
|
||||
@@ -106,7 +131,11 @@ func classifyNoAccountErrorFromGin(
|
||||
if c != nil && c.Request != nil {
|
||||
ctx = c.Request.Context()
|
||||
}
|
||||
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
|
||||
classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
|
||||
if classification.ModelNotFound {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
func classifyOpenAICompatibleNoAccountErrorFromGin(
|
||||
|
||||
@@ -61,6 +61,21 @@ func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifySelectionFailureError_RateLimitedPool(t *testing.T) {
|
||||
fallback := noAccountErrorClassification{Status: http.StatusServiceUnavailable, ErrType: "api_error", Message: "Service temporarily unavailable"}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||||
fallback,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||||
require.Contains(t, got.Message, "rate-limited")
|
||||
require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("model_rate_limited=0"), fallback))
|
||||
require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("no available accounts"), fallback))
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
@@ -113,6 +128,8 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
|
||||
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
|
||||
require.NotNil(t, fd.calls[0].GroupID)
|
||||
require.Equal(t, int64(42), *fd.calls[0].GroupID)
|
||||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||||
}
|
||||
|
||||
func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
|
||||
@@ -134,6 +151,8 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Len(t, fd.calls, 1)
|
||||
require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
|
||||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||||
|
||||
logErr := openAICompatibleSelectionErrorForLog(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
|
||||
@@ -142,6 +161,18 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.
|
||||
require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.False(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Empty(t, service.OpsClientBusinessLimitedReason(c))
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||||
@@ -200,4 +231,6 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||||
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||||
}
|
||||
@@ -30,8 +30,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI groups")
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
@@ -75,6 +75,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
requestedModel := strings.TrimSpace(modelResult.String())
|
||||
if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups")
|
||||
return
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", requestedModel))
|
||||
setOpsRequestContext(c, requestedModel, false)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
|
||||
@@ -109,6 +113,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
switchCount := 0
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
@@ -182,7 +187,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
|
||||
|
||||
if err == nil {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), true, nil)
|
||||
if result != nil {
|
||||
h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID)
|
||||
}
|
||||
@@ -191,7 +196,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if !errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -199,7 +204,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
@@ -211,6 +216,26 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai_alpha_search.same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
zap.Duration("retry_delay", retryDelay),
|
||||
)
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
h.gatewayService.RecordOpenAIAccountSwitch()
|
||||
failedAccountIDs[account.ID] = struct{}{}
|
||||
lastFailoverErr = failoverErr
|
||||
|
||||
@@ -55,6 +55,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
|
||||
return
|
||||
}
|
||||
logRequestBodyReadFailure(reqLog, c.Request, err)
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
@@ -88,6 +89,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
return
|
||||
}
|
||||
if _, err := service.ValidateOpenAIServiceTierField(body); err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if service.IsGPTImageGenerationModel(reqModel) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
|
||||
return
|
||||
@@ -182,6 +187,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
@@ -239,11 +245,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}()
|
||||
return h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "")
|
||||
}()
|
||||
cyberBlockKeyChat := ""
|
||||
var cyberBlockBodyChat []byte
|
||||
if service.GetOpsCyberPolicy(c) != nil {
|
||||
cyberBlockKeyChat = service.CyberSessionBlockKey(apiKey.ID, c, body)
|
||||
cyberBlockBodyChat = body
|
||||
}
|
||||
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyChat, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body))
|
||||
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockBodyChat, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body))
|
||||
|
||||
forwardDurationMs := time.Since(forwardStart).Milliseconds()
|
||||
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
|
||||
@@ -255,6 +261,48 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
if err == nil && result != nil && result.FirstTokenMs != nil {
|
||||
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
|
||||
}
|
||||
// #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
|
||||
// 入账;failover 错误恒定 result=nil,不会重复计费。
|
||||
submitChatUsage := func(res *service.OpenAIForwardResult) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: res,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.chat_completions"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if result != nil && result.ImageCount > 0 {
|
||||
reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result",
|
||||
@@ -273,11 +321,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.gatewayService.ObserveOpenAIAccountHealthFailure(c.Request.Context(), account, err)
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err)
|
||||
}
|
||||
if !failoverErr.ShouldRetryNextAccount() {
|
||||
h.handleFailoverExhausted(c, failoverErr, streamStarted)
|
||||
@@ -285,8 +334,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
// Pool mode: retry on the same account
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
retryLimit := effectiveSameAccountRetryLimit(failoverErr, account)
|
||||
if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
|
||||
@@ -324,7 +373,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err)
|
||||
upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
|
||||
wroteFallback := false
|
||||
if !upstreamErrorAlreadyCommunicated {
|
||||
@@ -339,51 +388,17 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
|
||||
zap.Error(err),
|
||||
)
|
||||
submitChatUsage(result)
|
||||
return
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, nil)
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.chat_completions"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
submitChatUsage(result)
|
||||
reqLog.Debug("openai_chat_completions.request_completed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("switch_count", switchCount),
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
// Codex CLI and the Codex desktop app refresh their model picker from
|
||||
// GET {base_url}/models?client_version=... (custom provider mode) or
|
||||
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
|
||||
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
|
||||
// provider-compatibility normalization and use a short-lived, asynchronously
|
||||
// revalidated cache to tolerate canceled client requests.
|
||||
// here. Groups with explicit account model mappings are generated locally;
|
||||
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
|
||||
// receive provider-compatibility normalization plus short-lived caching.
|
||||
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -27,8 +27,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
|
||||
return
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups")
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI and Composite groups")
|
||||
return
|
||||
}
|
||||
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
ifNoneMatch,
|
||||
)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeCodexModelsManifestResponse(c, configuredManifest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,7 +74,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
|
||||
// The client ETag represents the final group-specific body, so fetch the
|
||||
// source manifest before applying local filtering and alias metadata.
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "")
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -70,18 +90,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
writeCodexModelsManifestResponse(c, manifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type codexModelsFailoverAccountRepo struct {
|
||||
@@ -43,6 +45,14 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -116,6 +126,249 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(43)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "custom-openai",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
}
|
||||
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("first body did not include the explicitly selected models: %s", body)
|
||||
}
|
||||
oldETag := first.Header().Get("ETag")
|
||||
if oldETag == "" {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
|
||||
}
|
||||
if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("second body was not the filtered manifest: %s", body)
|
||||
}
|
||||
if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag {
|
||||
t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag)
|
||||
}
|
||||
|
||||
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
|
||||
if third.Code != http.StatusNotModified {
|
||||
t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String())
|
||||
}
|
||||
if third.Body.Len() != 0 {
|
||||
t.Fatalf("third body: got %q, want empty", third.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "shared-api-key",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-shared",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
groupA := &service.Group{
|
||||
ID: 91,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
}
|
||||
groupB := &service.Group{
|
||||
ID: 92,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
}
|
||||
|
||||
firstA := performCodexModelsRequestForGroup(t, handler, groupA, "")
|
||||
require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String())
|
||||
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA))
|
||||
|
||||
firstB := performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String())
|
||||
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB))
|
||||
|
||||
etagA := firstA.Header().Get("ETag")
|
||||
require.NotEmpty(t, etagA)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*httptest.ResponseRecorder, 8)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
if index%2 == 0 {
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA)
|
||||
return
|
||||
}
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sawGroupB := false
|
||||
for _, recorder := range results {
|
||||
require.NotNil(t, recorder)
|
||||
switch recorder.Code {
|
||||
case http.StatusNotModified:
|
||||
require.Empty(t, recorder.Body.Bytes())
|
||||
case http.StatusOK:
|
||||
slugs := codexHandlerManifestSlugs(t, recorder)
|
||||
if len(slugs) == 1 && slugs[0] == "model-b" {
|
||||
sawGroupB = true
|
||||
continue
|
||||
}
|
||||
require.Equal(t, []string{"model-a"}, slugs)
|
||||
default:
|
||||
t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
require.True(t, sawGroupB)
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
|
||||
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(44)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "ark-compatible",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 0,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-ark",
|
||||
"base_url": "https://ark.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"glm-5.3": "glm-5.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "chatgpt-oauth",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 1,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-test",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
|
||||
recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
}, "")
|
||||
|
||||
if got := upstream.calls(); len(got) != 0 {
|
||||
t.Fatalf("upstream account calls: got %v, want none", got)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
|
||||
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
|
||||
}
|
||||
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
|
||||
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
|
||||
|
||||
recorder := performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformComposite)
|
||||
|
||||
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
|
||||
t.Fatalf("upstream account calls: got %v, want %v", got, want)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
|
||||
retryableStatuses := []int{
|
||||
http.StatusTooManyRequests,
|
||||
@@ -135,8 +388,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: an API-key upstream without /models is excluded only for this discovery request.
|
||||
func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(status)
|
||||
recorder := performCodexModelsRequest(t, handler, groupID)
|
||||
|
||||
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
|
||||
t.Fatalf("upstream account calls: got %v, want %v", got, want)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -170,9 +438,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
}
|
||||
|
||||
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
@@ -180,7 +446,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
600,
|
||||
}
|
||||
for _, status := range statuses {
|
||||
@@ -287,19 +552,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
|
||||
}
|
||||
|
||||
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
|
||||
if etag != "" {
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI},
|
||||
GroupID: &group.ID,
|
||||
Group: group,
|
||||
})
|
||||
|
||||
handler.CodexModels(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
slugs := make([]string, 0, len(envelope.Models))
|
||||
for _, model := range envelope.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 {
|
||||
t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String())
|
||||
}
|
||||
model := envelope.Models[0]
|
||||
if got := model["slug"]; got != slug {
|
||||
t.Fatalf("slug: got %v, want %q", got, slug)
|
||||
}
|
||||
if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 {
|
||||
t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"])
|
||||
}
|
||||
if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" {
|
||||
t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"])
|
||||
}
|
||||
if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 {
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "text" {
|
||||
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
func equalInt64Slices(got, want []int64) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
|
||||
@@ -215,7 +215,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), false, nil, err)
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("openai_embeddings.failover_aborted_client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -239,7 +239,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), false, nil, err)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, nil)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
// CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403):
|
||||
// sanitizeGroupMessagesDispatchFields 对非 openai/composite 平台强制 AllowMessagesDispatch
|
||||
// =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通
|
||||
//(Claude Code 主用例)永远不可达。composite 分组解析到 grok/CN 目标时按
|
||||
// 目标平台豁免,解析到 openai 目标仍受其可配置开关控制。
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
|
||||
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
||||
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
||||
}
|
||||
|
||||
// 非回归:openai 分组仍受开关控制。
|
||||
openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}}
|
||||
require.False(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOff))
|
||||
openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}}
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOn))
|
||||
}
|
||||
|
||||
func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newCompositeCtx := func(model string, allow bool) (*gin.Context, *service.APIKey) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: allow}}
|
||||
ensureCompositeTargetPlatform(c, apiKey, model)
|
||||
return c, apiKey
|
||||
}
|
||||
|
||||
// 解析到 grok/CN 目标:与对应独立分组同语义豁免。
|
||||
for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2"} {
|
||||
c, apiKey := newCompositeCtx(model, false)
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "model=%s", model)
|
||||
}
|
||||
|
||||
// 解析到 openai 目标:受 composite 分组自身开关控制。
|
||||
c, apiKey := newCompositeCtx("gpt-5.5", false)
|
||||
require.False(t, allowOpenAICompatibleMessagesDispatch(c, apiKey))
|
||||
c, apiKey = newCompositeCtx("gpt-5.5", true)
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey))
|
||||
|
||||
// 未解析出目标平台:保持拒绝,不放宽。
|
||||
cNone, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
cNone.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
require.False(t, allowOpenAICompatibleMessagesDispatch(cNone,
|
||||
&service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}}))
|
||||
}
|
||||
|
||||
// composite 解析到 grok/CN 目标时,Group 级调度映射(gpt-5.x 默认值为 openai
|
||||
// 专属)不得注入,模型改写完全交给账号级 model_mapping。
|
||||
func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "grok-4.3"} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||
ensureCompositeTargetPlatform(c, apiKey, model)
|
||||
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
|
||||
}
|
||||
}
|
||||
@@ -25,54 +25,211 @@ func newCompactBodySignalTestContext(t *testing.T, path string, body []byte) *gi
|
||||
|
||||
func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2StaysOnResponses(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"stream":true,
|
||||
"store":true,
|
||||
"prompt_cache_key":"pck-signal-1",
|
||||
"reasoning":{"effort":"max","context":"all_turns"},
|
||||
"input":[
|
||||
{"type":"message","role":"user","content":"hello"},
|
||||
{"type":"compaction_trigger"}
|
||||
]
|
||||
}`)
|
||||
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
|
||||
c.Request.Header.Set("x-codex-beta-features", "responses_websockets_v2, remote_compaction_v2, another_feature")
|
||||
tests := []struct {
|
||||
name string
|
||||
betaHeader string
|
||||
userAgent string
|
||||
}{
|
||||
{name: "headerless"},
|
||||
{name: "unrelated_header", betaHeader: "responses_websockets_v2"},
|
||||
{name: "wrong_case_header", betaHeader: "REMOTE_COMPACTION_V2"},
|
||||
{name: "declared_header", betaHeader: "remote_compaction_v2"},
|
||||
{name: "codex_cli_user_agent", userAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"},
|
||||
{name: "codex_desktop_user_agent", userAgent: "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown"},
|
||||
}
|
||||
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
||||
require.True(t, ok)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"stream":true,
|
||||
"store":true,
|
||||
"prompt_cache_key":"pck-signal-1",
|
||||
"reasoning":{"effort":"max","context":"all_turns"},
|
||||
"input":[
|
||||
{"type":"message","role":"user","content":"hello"},
|
||||
{"type":"compaction_trigger"}
|
||||
]
|
||||
}`)
|
||||
c := newCompactBodySignalTestContext(t, "/v1/responses", body)
|
||||
if tt.betaHeader != "" {
|
||||
c.Request.Header.Set("x-codex-beta-features", tt.betaHeader)
|
||||
}
|
||||
if tt.userAgent != "" {
|
||||
c.Request.Header.Set("User-Agent", tt.userAgent)
|
||||
}
|
||||
|
||||
require.Equal(t, "/v1/responses", c.Request.URL.Path)
|
||||
require.False(t, isOpenAIRemoteCompactPath(c))
|
||||
require.Equal(t, body, normalized)
|
||||
require.True(t, gjson.GetBytes(normalized, "stream").Bool())
|
||||
require.True(t, gjson.GetBytes(normalized, "store").Bool())
|
||||
require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String())
|
||||
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String())
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
||||
require.True(t, ok)
|
||||
|
||||
reqStream, streamOK := parseOpenAICompatibleStream(normalized)
|
||||
require.True(t, streamOK)
|
||||
require.True(t, reqStream)
|
||||
require.Equal(t, "/v1/responses", c.Request.URL.Path)
|
||||
require.False(t, isOpenAILegacyCompactPath(c))
|
||||
require.Equal(t, body, normalized)
|
||||
require.True(t, gjson.GetBytes(normalized, "stream").Bool())
|
||||
require.True(t, gjson.GetBytes(normalized, "store").Bool())
|
||||
require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String())
|
||||
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String())
|
||||
legacyCompact := service.IsOpenAIResponsesCompactPath(c)
|
||||
nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
||||
require.False(t, legacyCompact)
|
||||
require.True(t, nativeV2)
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityResponses,
|
||||
openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
|
||||
|
||||
_, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest())
|
||||
require.False(t, seedExists)
|
||||
_, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest())
|
||||
require.False(t, streamMarkerExists)
|
||||
reqStream, streamOK := parseOpenAICompatibleStream(normalized)
|
||||
require.True(t, streamOK)
|
||||
require.True(t, reqStream)
|
||||
|
||||
_, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest())
|
||||
require.False(t, seedExists)
|
||||
_, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest())
|
||||
require.False(t, streamMarkerExists)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnResponses(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
body := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`)
|
||||
for _, path := range []string{"/v1/responses/", "/backend-api/codex/responses"} {
|
||||
for _, path := range []string{
|
||||
"/v1/responses/",
|
||||
"/openai/v1/responses",
|
||||
"/responses",
|
||||
"/backend-api/codex/responses",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
c := newCompactBodySignalTestContext(t, path, body)
|
||||
c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2")
|
||||
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, path, c.Request.URL.Path)
|
||||
require.Equal(t, body, normalized)
|
||||
legacyCompact := service.IsOpenAIResponsesCompactPath(c)
|
||||
nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
||||
require.False(t, legacyCompact)
|
||||
require.True(t, nativeV2)
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityResponses,
|
||||
openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesCompactionRoutingFlags(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
path string
|
||||
wantLegacyBefore bool
|
||||
wantNativeBefore bool
|
||||
wantLegacyAfter bool
|
||||
wantNativeAfter bool
|
||||
wantCapabilityAfter service.OpenAIEndpointCapability
|
||||
wantPathAfter string
|
||||
wantBodyUnchanged bool
|
||||
}{
|
||||
{
|
||||
name: "native_v2_stream_trigger",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses",
|
||||
wantLegacyBefore: false,
|
||||
wantNativeBefore: true,
|
||||
wantLegacyAfter: false,
|
||||
wantNativeAfter: true,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
||||
wantPathAfter: "/v1/responses",
|
||||
wantBodyUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "native_v2_without_trigger",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`),
|
||||
path: "/v1/responses",
|
||||
wantLegacyBefore: false,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: false,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
|
||||
wantPathAfter: "/v1/responses",
|
||||
wantBodyUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "explicit_compact",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses/compact",
|
||||
wantLegacyBefore: true,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: true,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
||||
wantPathAfter: "/v1/responses/compact",
|
||||
},
|
||||
{
|
||||
name: "nested_compact",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses/compact/detail",
|
||||
wantLegacyBefore: true,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: true,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
||||
wantPathAfter: "/v1/responses/compact/detail",
|
||||
},
|
||||
{
|
||||
name: "responses_subpath_with_native_signal",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses/resp_123/responses",
|
||||
wantLegacyBefore: false,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: false,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions,
|
||||
wantPathAfter: "/v1/responses/resp_123/responses",
|
||||
wantBodyUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "stream_false_promotes",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses",
|
||||
wantLegacyBefore: false,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: true,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
||||
wantPathAfter: "/v1/responses/compact",
|
||||
},
|
||||
{
|
||||
name: "stream_absent_promotes",
|
||||
body: []byte(`{"model":"gpt-5.6-sol","input":[{"type":"compaction_trigger"}]}`),
|
||||
path: "/v1/responses",
|
||||
wantLegacyBefore: false,
|
||||
wantNativeBefore: false,
|
||||
wantLegacyAfter: true,
|
||||
wantNativeAfter: false,
|
||||
wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses,
|
||||
wantPathAfter: "/v1/responses/compact",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newCompactBodySignalTestContext(t, tt.path, tt.body)
|
||||
legacyBefore := service.IsOpenAIResponsesCompactPath(c)
|
||||
nativeBefore := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(tt.body)
|
||||
require.Equal(t, tt.wantLegacyBefore, legacyBefore)
|
||||
require.Equal(t, tt.wantNativeBefore, nativeBefore)
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tt.wantPathAfter, c.Request.URL.Path)
|
||||
legacyAfter := service.IsOpenAIResponsesCompactPath(c)
|
||||
nativeAfter := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized)
|
||||
require.Equal(t, tt.wantLegacyAfter, legacyAfter)
|
||||
require.Equal(t, tt.wantNativeAfter, nativeAfter)
|
||||
require.Equal(t, tt.wantCapabilityAfter,
|
||||
openAIResponsesRequiredCapabilityForRequest(false, nativeAfter || legacyAfter, service.PlatformOpenAI))
|
||||
if tt.wantBodyUnchanged {
|
||||
require.Equal(t, tt.body, normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -106,31 +263,22 @@ func TestNormalizeOpenAIResponsesCompactRequest_NonRemoteV2BodySignalPromoted(t
|
||||
wantMarked bool
|
||||
}{
|
||||
{
|
||||
name: "no_header",
|
||||
body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
wantMarked: true,
|
||||
name: "stream_false_headerless",
|
||||
body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
||||
},
|
||||
{
|
||||
name: "unrelated_header",
|
||||
body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
betaHeader: "responses_websockets_v2",
|
||||
wantMarked: true,
|
||||
name: "stream_absent_headerless",
|
||||
body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
|
||||
},
|
||||
{
|
||||
name: "wrong_case_header",
|
||||
body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`),
|
||||
betaHeader: "REMOTE_COMPACTION_V2",
|
||||
wantMarked: true,
|
||||
},
|
||||
{
|
||||
name: "stream_false",
|
||||
name: "stream_false_declared_header",
|
||||
body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`),
|
||||
betaHeader: "remote_compaction_v2",
|
||||
},
|
||||
{
|
||||
name: "stream_absent",
|
||||
name: "stream_absent_wrong_case_header",
|
||||
body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`),
|
||||
betaHeader: "remote_compaction_v2",
|
||||
betaHeader: "REMOTE_COMPACTION_V2",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -163,7 +311,7 @@ func TestNormalizeOpenAIResponsesCompactRequest_NoTriggerUntouched(t *testing.T)
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "/v1/responses", c.Request.URL.Path)
|
||||
require.False(t, isOpenAIRemoteCompactPath(c))
|
||||
require.False(t, isOpenAILegacyCompactPath(c))
|
||||
require.Equal(t, body, normalized)
|
||||
require.True(t, gjson.GetBytes(normalized, "stream").Bool())
|
||||
}
|
||||
@@ -172,7 +320,6 @@ func TestNormalizeOpenAIResponsesCompactRequest_PathBasedNoDoubleSuffix(t *testi
|
||||
h := &OpenAIGatewayHandler{}
|
||||
body := []byte(`{"model":"gpt-5.5","stream":true,"store":true,"input":[{"type":"message","role":"user","content":"hello"}]}`)
|
||||
c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body)
|
||||
c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2")
|
||||
|
||||
normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -105,21 +105,29 @@ func captureHandlerStructuredLog(t *testing.T) (*handlerInMemoryLogSink, func())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenAIRemoteCompactPath(t *testing.T) {
|
||||
require.False(t, isOpenAIRemoteCompactPath(nil))
|
||||
func TestIsOpenAILegacyCompactPath(t *testing.T) {
|
||||
require.False(t, isOpenAILegacyCompactPath(nil))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
|
||||
require.True(t, isOpenAIRemoteCompactPath(c))
|
||||
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact/", nil)
|
||||
require.True(t, isOpenAIRemoteCompactPath(c))
|
||||
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
require.False(t, isOpenAIRemoteCompactPath(c))
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/v1/responses/compact", want: true},
|
||||
{path: "/v1/responses/compact/detail", want: true},
|
||||
{path: "/responses/compact/", want: true},
|
||||
{path: "/v1/responses", want: false},
|
||||
{path: "/openai/v1/responses", want: false},
|
||||
{path: "/responses", want: false},
|
||||
{path: "/backend-api/codex/responses", want: false},
|
||||
{path: "/v1/responses/resp_123/cancel", want: false},
|
||||
} {
|
||||
c.Request = httptest.NewRequest(http.MethodPost, test.path, nil)
|
||||
require.Equal(t, test.want, isOpenAILegacyCompactPath(c), test.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogOpenAIRemoteCompactOutcome_Succeeded(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
@@ -10,9 +11,124 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ResponsesInputTokens handles native OpenAI POST
|
||||
// /v1/responses/input_tokens requests without routing them through the normal
|
||||
// Responses generation and usage-recording pipeline.
|
||||
func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
reqLog := requestLogger(
|
||||
c,
|
||||
"handler.openai_gateway.responses_input_tokens",
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
if !h.ensureResponsesDependencies(c, reqLog) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
|
||||
if err != nil {
|
||||
if maxErr, ok := extractMaxBytesError(err); ok {
|
||||
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
||||
return
|
||||
}
|
||||
modelResult := gjson.GetBytes(body, "model")
|
||||
if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||
return
|
||||
}
|
||||
reqModel := strings.TrimSpace(modelResult.String())
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
|
||||
setOpsRequestContext(c, reqModel, false)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage {
|
||||
h.openAISecurityAuditError(c, decision)
|
||||
return
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("openai_input_tokens.billing_eligibility_check_failed", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
routingModel := reqModel
|
||||
forwardBody := body
|
||||
if channelMapping.Mapped {
|
||||
routingModel = channelMapping.MappedModel
|
||||
forwardBody = h.gatewayService.ReplaceModelInBody(body, routingModel)
|
||||
}
|
||||
|
||||
// Token counting is not billed, so it must not be excluded by the profit gate.
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context()))
|
||||
requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey)
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
|
||||
requestStart := time.Now()
|
||||
account, err := h.gatewayService.SelectAccountForTokenCount(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
sessionHash,
|
||||
routingModel,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
requestPlatform,
|
||||
)
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
if err != nil {
|
||||
reqLog.Warn("openai_input_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)))
|
||||
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
if err := h.gatewayService.ForwardResponsesInputTokens(c.Request.Context(), c, account, forwardBody); err != nil {
|
||||
reqLog.Error("openai_input_tokens.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// GrokCountTokens handles Anthropic-compatible count_tokens requests locally.
|
||||
// The route middleware already authenticates the API key and resolves the
|
||||
// group; this handler intentionally does not select an account or check billing.
|
||||
@@ -56,8 +172,7 @@ func (h *OpenAIGatewayHandler) GrokCountTokens(c *gin.Context) {
|
||||
}
|
||||
|
||||
// CountTokens handles Anthropic-compatible POST /v1/messages/count_tokens for OpenAI groups.
|
||||
// It validates billing and routes to an OpenAI token-count bridge without taking concurrency slots
|
||||
// or recording usage.
|
||||
// It validates billing and routes to an OpenAI token-count bridge without recording usage.
|
||||
func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
@@ -78,7 +193,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
|
||||
if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch {
|
||||
if !allowOpenAICompatibleMessagesDispatch(c, apiKey) {
|
||||
h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error",
|
||||
"This group does not allow /v1/messages dispatch")
|
||||
return
|
||||
@@ -117,12 +232,14 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
|
||||
reqModel := parsedReq.Model
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) {
|
||||
// composite+grok 在路由层已分流到 GrokCountTokens,这里可达的目标平台是
|
||||
// openai 与 CN 供应商;CN 账号由 ForwardCountTokensAsAnthropic 本地估算。
|
||||
if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) {
|
||||
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
|
||||
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
|
||||
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel)
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream))
|
||||
|
||||
setOpsRequestContext(c, reqModel, false)
|
||||
@@ -151,18 +268,12 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
if preferredMappedModel != "" {
|
||||
currentRoutingModel = preferredMappedModel
|
||||
}
|
||||
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
account, err := h.gatewayService.SelectAccountForTokenCount(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
currentRoutingModel,
|
||||
nil,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
openAICompatibleRequestPlatform(c.Request.Context(), apiKey),
|
||||
)
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
@@ -176,7 +287,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
h.anthropicErrorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
if account == nil {
|
||||
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
@@ -185,11 +296,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
account := selection.Account
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
defer selection.ReleaseFunc()
|
||||
}
|
||||
forwardBody := mappedBodyForMessages(channelMapping.Mapped, channelMapping.MappedModel)
|
||||
defaultMappedModel := preferredMappedModel
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestGatewayChatCredentialStopDoesNotSelectAnotherAccountAndReturnsSafe503(t *testing.T) {
|
||||
@@ -64,6 +65,113 @@ func TestGatewayChatAntigravityCredentialFailureReturnsActionableMessage(t *test
|
||||
require.NotContains(t, strings.ToLower(recorder.Body.String()), "refresh_token")
|
||||
}
|
||||
|
||||
func TestOpenAIAccessStateCredentialFailureUsesTypedSafeResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Stage: service.GatewayFailureStageAccountAuth,
|
||||
Scope: service.GatewayFailureScopeAccount,
|
||||
Reason: service.OpenAIUpstreamAccessStateReason,
|
||||
NextAccountAction: service.NextAccountRetry,
|
||||
ClientStatusCode: http.StatusBadGateway,
|
||||
ClientMessage: "Upstream access is temporarily unavailable, please retry later",
|
||||
ResponseBody: []byte(`{"error":{"message":"Your workspace is deactivated","token":"must-not-leak"}}`),
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "Upstream access is temporarily unavailable")
|
||||
require.NotContains(t, strings.ToLower(recorder.Body.String()), "deactivated")
|
||||
require.NotContains(t, recorder.Body.String(), "must-not-leak")
|
||||
}
|
||||
|
||||
func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
message := "Our servers are currently overloaded. Please try again later."
|
||||
failoverErr := &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ResponseBody: []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`),
|
||||
RetryableOnSameAccount: true,
|
||||
RequestScopedTransient: true,
|
||||
ClientStatusCode: http.StatusServiceUnavailable,
|
||||
ClientMessage: message,
|
||||
}
|
||||
|
||||
t.Run("native_openai", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, failoverErr, false)
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
|
||||
require.NotContains(t, recorder.Body.String(), "server_is_overloaded")
|
||||
})
|
||||
|
||||
t.Run("responses_compat", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
(&GatewayHandler{}).handleResponsesFailoverExhausted(c, failoverErr, false)
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.code").String())
|
||||
require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
|
||||
})
|
||||
|
||||
t.Run("anthropic_compat", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
(&OpenAIGatewayHandler{}).handleAnthropicFailoverExhausted(c, failoverErr, false)
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Equal(t, "api_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
official := "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"server_error\",\"message\":\"official failure\"}}}\n\n"
|
||||
_, err := c.Writer.Write([]byte(official))
|
||||
require.NoError(t, err)
|
||||
service.MarkOpsStreamError(c, "server_error", "official failure", http.StatusBadGateway)
|
||||
|
||||
(&GatewayHandler{}).handleResponsesFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: []byte(`{"error":{"message":"fallback failure"}}`),
|
||||
}, true)
|
||||
|
||||
require.Equal(t, official, recorder.Body.String())
|
||||
streamErr, ok := service.GetOpsStreamError(c)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "official failure", streamErr.Message)
|
||||
|
||||
markerRecorder := httptest.NewRecorder()
|
||||
markerContext, _ := gin.CreateTestContext(markerRecorder)
|
||||
(&GatewayHandler{}).handleResponsesFailoverExhausted(markerContext, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
}, true)
|
||||
require.Contains(t, markerRecorder.Body.String(), "event: response.failed")
|
||||
require.Equal(t, 1, strings.Count(markerRecorder.Body.String(), "event: response.failed"))
|
||||
streamErr, ok = service.GetOpsStreamError(markerContext)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, http.StatusTooManyRequests, streamErr.IntendedStatus)
|
||||
require.Equal(t, "rate_limit_error", streamErr.ErrType)
|
||||
|
||||
heartbeatRecorder := httptest.NewRecorder()
|
||||
heartbeatContext, _ := gin.CreateTestContext(heartbeatRecorder)
|
||||
heartbeat := ": keepalive\n\n"
|
||||
written, err := heartbeatRecorder.Write([]byte(heartbeat))
|
||||
require.NoError(t, err)
|
||||
recordGatewayStreamHeartbeat(heartbeatContext, written)
|
||||
(&GatewayHandler{}).handleResponsesFailoverExhausted(heartbeatContext, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
}, true)
|
||||
require.True(t, strings.HasPrefix(heartbeatRecorder.Body.String(), heartbeat))
|
||||
require.Equal(t, 1, strings.Count(heartbeatRecorder.Body.String(), "event: response.failed"))
|
||||
}
|
||||
|
||||
func TestGatewayChatInferenceExhaustionRestoresRetryAfter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -197,7 +305,7 @@ func TestOpsClassificationTreatsCredentialFailureAsAuthNotInference(t *testing.T
|
||||
require.Equal(t, http.StatusForbidden, entry.UpstreamErrors[0].UpstreamStatusCode)
|
||||
}
|
||||
|
||||
func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T) {
|
||||
func TestOpsRecoveredCredentialFailoverDoesNotCreateRequestError(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
@@ -220,18 +328,75 @@ func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "account_auth", job.entry.ErrorPhase)
|
||||
require.Equal(t, "provider", job.entry.ErrorOwner)
|
||||
require.Equal(t, "gateway", job.entry.ErrorSource)
|
||||
require.Contains(t, job.entry.ErrorMessage, "Recovered account authentication failure")
|
||||
require.NotContains(t, job.entry.ErrorMessage, "403")
|
||||
require.NotContains(t, job.entry.ErrorMessage, "earlier inference failure")
|
||||
require.NotNil(t, job.entry.UpstreamStatusCode)
|
||||
require.Zero(t, *job.entry.UpstreamStatusCode)
|
||||
require.Nil(t, job.entry.UpstreamErrors)
|
||||
require.Equal(t, http.StatusOK, job.entry.StatusCode)
|
||||
require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase)
|
||||
require.NotNil(t, job.entry.UpstreamErrorsJSON)
|
||||
events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 2)
|
||||
require.Equal(t, http.StatusForbidden, events[0].UpstreamStatusCode)
|
||||
require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[1].Stage)
|
||||
}
|
||||
|
||||
func TestOpsWebSocketCredentialFailoverSuccessDoesNotCreateRequestError(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.GET("/openai/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
|
||||
Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
|
||||
}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "websocket")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusOK, job.entry.StatusCode)
|
||||
require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase)
|
||||
require.NotNil(t, job.entry.UpstreamErrorsJSON)
|
||||
events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[0].Stage)
|
||||
}
|
||||
|
||||
func TestOpsWebSocketCredentialFailoverExhaustedIsRecorded(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.GET("/openai/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount),
|
||||
Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action",
|
||||
}})
|
||||
closeOpenAIWSFailoverExhausted(c, nil, &service.UpstreamFailoverError{
|
||||
Stage: service.GatewayFailureStageAccountAuth,
|
||||
Scope: service.GatewayFailureScopeAccount,
|
||||
Reason: service.GrokCredentialReasonRevoked,
|
||||
NextAccountAction: service.NextAccountStop,
|
||||
})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil)
|
||||
request.Header.Set("Connection", "Upgrade")
|
||||
request.Header.Set("Upgrade", "websocket")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "account_auth", job.entry.ErrorPhase)
|
||||
require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
|
||||
require.Equal(t, service.GrokCredentialUnavailableClientMessage, job.entry.ErrorMessage)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func TestRecordCyberPolicyIfMarked_NoMark(t *testing.T) {
|
||||
c := newTestGinContext()
|
||||
h := &OpenAIGatewayHandler{}
|
||||
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
|
||||
// Flag must NOT be set when there was no mark.
|
||||
require.False(t, c.GetBool(cyberPolicyRecordedKey),
|
||||
@@ -47,14 +47,14 @@ func TestRecordCyberPolicyIfMarked_WithMark(t *testing.T) {
|
||||
|
||||
// First call: should set the flag.
|
||||
require.NotPanics(t, func() {
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
})
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey),
|
||||
"cyberPolicyRecordedKey must be true after first call with a mark")
|
||||
|
||||
// Second call: flag already set — must be a no-op (idempotent).
|
||||
require.NotPanics(t, func() {
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
|
||||
})
|
||||
// Flag should still be true (not toggled or cleared).
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey),
|
||||
@@ -75,7 +75,7 @@ func TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, nil, service.ChannelUsageFields{}, "")
|
||||
})
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{}
|
||||
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn1", UpstreamStatus: 200})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
|
||||
clearCyberPolicyTurnState(c)
|
||||
@@ -97,7 +97,7 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
|
||||
|
||||
// turn2: a fresh cyber hit must be recordable again.
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn2", UpstreamStatus: 200})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
|
||||
}
|
||||
@@ -139,6 +139,23 @@ func TestRejectIfCyberSessionBlocked_FailOpen(t *testing.T) {
|
||||
require.False(t, h2.rejectIfCyberSessionBlocked(c, key, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil gateway service → pass")
|
||||
}
|
||||
|
||||
func TestBuildCyberSessionBlockWritePlanCombinesExplicitAndTranscriptKeys(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
|
||||
c := newTestGinContext()
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(string(body)))
|
||||
c.Request.RemoteAddr = "203.0.113.44:12345"
|
||||
c.Request.Header.Set("User-Agent", "client/1.2.3")
|
||||
|
||||
plan := buildCyberSessionBlockWritePlan(7, c, body)
|
||||
require.Len(t, plan.keys, 2)
|
||||
require.NotEmpty(t, plan.scopeKey)
|
||||
|
||||
c.Request.Header.Set("session_id", "sess-explicit")
|
||||
plan = buildCyberSessionBlockWritePlan(7, c, body)
|
||||
require.Len(t, plan.keys, 3)
|
||||
require.NotEmpty(t, plan.scopeKey)
|
||||
}
|
||||
|
||||
// TestRecordCyberPolicyIfMarked_BlockKeyPlumbed verifies the 6th param is
|
||||
// accepted and a non-empty key with nil gateway service does not panic
|
||||
// (write-side guards live in the service layer).
|
||||
@@ -147,7 +164,7 @@ func TestRecordCyberPolicyIfMarked_BlockKeyPlumbed(t *testing.T) {
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "x", UpstreamStatus: 400})
|
||||
h := &OpenAIGatewayHandler{}
|
||||
require.NotPanics(t, func() {
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "deadbeef", service.ChannelUsageFields{}, "")
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, []byte(`{"input":"deadbeef"}`), service.ChannelUsageFields{}, "")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -646,21 +646,21 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-fable-5"))
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-fable-5"))
|
||||
})
|
||||
|
||||
t.Run("uses_family_default_when_no_override", func(t *testing.T) {
|
||||
apiKey := &service.APIKey{Group: &service.Group{}}
|
||||
require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001"))
|
||||
require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-opus-4-6"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-haiku-4-5-20251001"))
|
||||
})
|
||||
|
||||
t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) {
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, nil, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{}, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{Group: &service.Group{}}, "gpt-5.4"))
|
||||
})
|
||||
|
||||
t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) {
|
||||
@@ -672,8 +672,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
|
||||
Platform: service.PlatformGrok,
|
||||
},
|
||||
}
|
||||
require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok"))
|
||||
require.Equal(t, "grok-4.6", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok"))
|
||||
})
|
||||
|
||||
t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) {
|
||||
@@ -682,8 +682,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
},
|
||||
}
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "gpt-5.4"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -855,7 +855,7 @@ func TestOpenAIResponses_RejectsMessageIDAsPreviousResponseID(t *testing.T) {
|
||||
require.Contains(t, w.Body.String(), "previous_response_id must be a response.id")
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_RejectsHTTPContinuationPreviousResponseID(t *testing.T) {
|
||||
func TestOpenAIResponses_AcceptsHTTPContinuationPreviousResponseIDBeforeRouting(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -877,11 +877,59 @@ func TestOpenAIResponses_RejectsHTTPContinuationPreviousResponseID(t *testing.T)
|
||||
})
|
||||
|
||||
h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil)
|
||||
require.NoError(t, h.gatewayService.BindOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_123456", 1, 101))
|
||||
h.Responses(c)
|
||||
|
||||
require.NotEqual(t, http.StatusBadRequest, w.Code)
|
||||
require.NotContains(t, w.Body.String(), "Responses WebSocket v2")
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_RejectsHTTPContinuationOwnedByAnotherUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(
|
||||
`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_other_tenant","input":"hello"}`,
|
||||
))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
groupID := int64(2)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 202,
|
||||
UserID: 2,
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 2},
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 2, Concurrency: 1})
|
||||
|
||||
h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil)
|
||||
require.NoError(t, h.gatewayService.BindOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_other_tenant", 1, 101))
|
||||
h.Responses(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "Responses WebSocket v2")
|
||||
require.Contains(t, w.Body.String(), "previous_response_id")
|
||||
require.Contains(t, w.Body.String(), "previous_response_id is not available for this user")
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_RejectsUnownedHTTPContinuation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(
|
||||
`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_unknown","input":"hello"}`,
|
||||
))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
groupID := int64(2)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ID: 101, UserID: 1, GroupID: &groupID, User: &service.User{ID: 1}})
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||
|
||||
h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil)
|
||||
h.Responses(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "previous_response_id is not available for this user")
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_FunctionCallOutputHTTPGuidanceDoesNotSuggestPreviousResponseReuse(t *testing.T) {
|
||||
@@ -1412,8 +1460,8 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
|
||||
})
|
||||
|
||||
require.Len(t, got.upstreamPayloads, 2)
|
||||
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
|
||||
require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
|
||||
require.Equal(t, "sol-channel", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
|
||||
require.Equal(t, "terra-channel", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
|
||||
require.Len(t, got.clientEvents, 2)
|
||||
require.Equal(t, "sol", gjson.GetBytes(got.clientEvents[0], "response.model").String())
|
||||
require.Equal(t, "terra", gjson.GetBytes(got.clientEvents[1], "response.model").String())
|
||||
@@ -1422,16 +1470,16 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
|
||||
require.Equal(t, "sol", got.logs[0].Model)
|
||||
require.Equal(t, "sol", got.logs[0].RequestedModel)
|
||||
require.NotNil(t, got.logs[0].UpstreamModel)
|
||||
require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel)
|
||||
require.Equal(t, "sol-channel", *got.logs[0].UpstreamModel)
|
||||
require.NotNil(t, got.logs[0].ModelMappingChain)
|
||||
require.Equal(t, "sol→sol-channel→gpt-5.6-sol", *got.logs[0].ModelMappingChain)
|
||||
require.Equal(t, "sol→sol-channel", *got.logs[0].ModelMappingChain)
|
||||
|
||||
require.Equal(t, "terra", got.logs[1].Model)
|
||||
require.Equal(t, "terra", got.logs[1].RequestedModel)
|
||||
require.NotNil(t, got.logs[1].UpstreamModel)
|
||||
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
|
||||
require.Equal(t, "terra-channel", *got.logs[1].UpstreamModel)
|
||||
require.NotNil(t, got.logs[1].ModelMappingChain)
|
||||
require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
|
||||
require.Equal(t, "terra→terra-channel", *got.logs[1].ModelMappingChain)
|
||||
require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12,
|
||||
"each turn must be billed with its own channel-mapped model")
|
||||
}
|
||||
@@ -1593,6 +1641,29 @@ func TestOpenAIWSTurnBillingModelPreservesImagePricingModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) {
|
||||
account := &service.Account{
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"public": "billing"},
|
||||
"compact_model_mapping": map[string]any{"public": "compact-actual"},
|
||||
},
|
||||
}
|
||||
|
||||
reported := &service.OpenAIForwardResult{UpstreamModel: "observed-actual"}
|
||||
require.Equal(t, "observed-actual", openAIAccountScheduleModel(nil, account, "public", true, reported))
|
||||
require.Equal(t, "compact-actual", openAIAccountScheduleModel(nil, account, "public", true, nil))
|
||||
require.Equal(t, "billing", openAIAccountScheduleModel(nil, account, "public", false, nil))
|
||||
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
service.SetOpsUpstreamModel(c, "attempt-actual")
|
||||
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
|
||||
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
|
||||
}
|
||||
|
||||
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
|
||||
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
|
||||
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIWSNextAttemptMessageUsesCurrentTurnPayload(t *testing.T) {
|
||||
firstMessage := []byte(`{"type":"response.create","input":"first"}`)
|
||||
currentTurn := []byte(`{"type":"response.create","input":"turn-281"}`)
|
||||
|
||||
next, ok := openAIWSNextAttemptMessage(firstMessage, currentTurn, true)
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, currentTurn, next)
|
||||
next[0] = 'x'
|
||||
require.Equal(t, byte('{'), currentTurn[0], "retry payload must be cloned")
|
||||
}
|
||||
|
||||
func TestOpenAIWSNextAttemptMessageRejectsMissingCurrentTurnPayload(t *testing.T) {
|
||||
next, ok := openAIWSNextAttemptMessage([]byte(`{"type":"response.create"}`), nil, true)
|
||||
|
||||
require.False(t, ok)
|
||||
require.Nil(t, next)
|
||||
}
|
||||
|
||||
func TestOpenAIWSNextAttemptMessageKeepsInitialMessageForFirstTurnFailover(t *testing.T) {
|
||||
firstMessage := []byte(`{"type":"response.create","input":"first"}`)
|
||||
|
||||
next, ok := openAIWSNextAttemptMessage(firstMessage, nil, false)
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, firstMessage, next)
|
||||
}
|
||||
@@ -271,7 +271,11 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
var imageUpstreamErr *service.OpenAIImagesUpstreamError
|
||||
if errors.As(err, &imageUpstreamErr) {
|
||||
retryableServerError := service.IsOpenAIImagesRetryableUpstreamError(imageUpstreamErr)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), !retryableServerError, nil)
|
||||
if retryableServerError {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, nil)
|
||||
}
|
||||
logEvent := "openai.images.upstream_user_error"
|
||||
if retryableServerError {
|
||||
logEvent = "openai.images.upstream_server_error_after_flush"
|
||||
@@ -287,7 +291,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
}
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err)
|
||||
if service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeForward {
|
||||
reqLog.Warn("openai.images.upstream_failover_skipped_after_flush",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -304,19 +308,21 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
retryLimit := effectiveSameAccountRetryLimit(failoverErr, account)
|
||||
if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
|
||||
reqLog.Warn("openai.images.pool_mode_same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
zap.Duration("retry_delay", retryDelay),
|
||||
)
|
||||
select {
|
||||
case <-requestCtx.Done():
|
||||
return
|
||||
case <-time.After(sameAccountRetryDelay):
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -341,7 +347,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err)
|
||||
upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
|
||||
wroteFallback := false
|
||||
if !upstreamErrorAlreadyCommunicated {
|
||||
@@ -366,9 +372,9 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
|
||||
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, nil)
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,7 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI {
|
||||
if apiKey.Group == nil || (apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite) {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform")
|
||||
return
|
||||
}
|
||||
@@ -43,6 +44,18 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String())
|
||||
if !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI) {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live only supports OpenAI models for Composite groups")
|
||||
return
|
||||
}
|
||||
if upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && upstreamModel != model {
|
||||
rewrittenSession, rewriteErr := sjson.SetBytes(request.Session, "model", upstreamModel)
|
||||
if rewriteErr != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply Composite model route")
|
||||
return
|
||||
}
|
||||
request.Session = rewrittenSession
|
||||
}
|
||||
reqLog := requestLogger(
|
||||
c,
|
||||
"handler.openai_gateway.live",
|
||||
@@ -231,6 +244,6 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) {
|
||||
func liveEnabledForAPIKey(apiKey *service.APIKey) bool {
|
||||
return apiKey != nil &&
|
||||
apiKey.Group != nil &&
|
||||
apiKey.Group.Platform == service.PlatformOpenAI &&
|
||||
(apiKey.Group.Platform == service.PlatformOpenAI || apiKey.Group.Platform == service.PlatformComposite) &&
|
||||
apiKey.Group.AllowLive
|
||||
}
|
||||
@@ -87,6 +87,9 @@ func TestLiveEnabledForAPIKey(t *testing.T) {
|
||||
require.True(t, liveEnabledForAPIKey(&service.APIKey{
|
||||
Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true},
|
||||
}))
|
||||
require.True(t, liveEnabledForAPIKey(&service.APIKey{
|
||||
Group: &service.Group{Platform: service.PlatformComposite, AllowLive: true},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestLiveAttestationErrorIsExplicit(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,7 @@ package handler
|
||||
// 槽位终检与生图跳门回归(handler 半程):
|
||||
// - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选,
|
||||
// 不写响应、不绑定粘连;
|
||||
// - openAIResponsesRequiredCapability 的生图意图映射钉死(scheduler 的
|
||||
// 跳门条件依赖 CapabilityResponses ⇔ 显式生图意图这一耦合)。
|
||||
// - openAIResponsesRequiredCapability 的请求能力映射覆盖生图与原生远程压缩。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -141,10 +140,10 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// scheduler 跳门条件依赖"CapabilityResponses 仅在显式生图意图时被要求"这一
|
||||
// 映射;后续若扩展该 capability 的用途,本测试失败提示同步收窄跳门条件。
|
||||
func TestOpenAIResponsesRequiredCapabilityPinsImageIntentMapping(t *testing.T) {
|
||||
func TestOpenAIResponsesRequiredCapabilityForRequest(t *testing.T) {
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapability(true, service.PlatformOpenAI))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(false, service.PlatformOpenAI))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(true, service.PlatformGrok))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformOpenAI))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformGrok))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResponsesInputTokensIsClassifiedAsTokenCountRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil)
|
||||
|
||||
require.True(t, isCountTokensRequest(c))
|
||||
require.True(t, isTokenCountRequestPath("/responses/input_tokens"))
|
||||
require.False(t, isTokenCountRequestPath("/v1/responses"))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 非法 service_tier 必须在两个 OpenAI 端点(/v1/responses、/v1/chat/completions)
|
||||
// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier
|
||||
// 校验处短路,不会进入账号选择/重试。
|
||||
//
|
||||
// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由
|
||||
// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler
|
||||
// 测试走入真实账号选择/重试路径。
|
||||
|
||||
func newServiceTierHandlerTest(t *testing.T) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil),
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(
|
||||
&helperConcurrencyCacheStub{userSeq: []bool{true}},
|
||||
)},
|
||||
cfg: &config.Config{},
|
||||
imageLimiter: &imageConcurrencyLimiter{},
|
||||
}
|
||||
}
|
||||
|
||||
func runOpenAIHandlerServiceTierTest(t *testing.T, path, body string, handler func(h *OpenAIGatewayHandler, c *gin.Context)) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
groupID := int64(6401)
|
||||
userID := int64(6402)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 6403,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
},
|
||||
User: &service.User{ID: userID, Status: service.StatusActive},
|
||||
})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1})
|
||||
|
||||
handler(newServiceTierHandlerTest(t), c)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerResponses_InvalidServiceTierRejected400(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"model":"gpt-5.5","input":"hi","service_tier":"turbo"}`,
|
||||
`{"model":"gpt-5.5","input":"hi","service_tier":"SPEED"}`,
|
||||
`{"model":"gpt-5.5","input":"hi","service_tier":""}`,
|
||||
`{"model":"gpt-5.5","input":"hi","service_tier":123}`,
|
||||
`{"model":"gpt-5.5","input":"hi","service_tier":{}}`,
|
||||
} {
|
||||
rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) {
|
||||
h.Responses(c)
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body)
|
||||
require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body)
|
||||
require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"turbo"}`,
|
||||
`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"ultra"}`,
|
||||
`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":""}`,
|
||||
`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":["priority"]}`,
|
||||
} {
|
||||
rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) {
|
||||
h.ChatCompletions(c)
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body)
|
||||
require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body)
|
||||
require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body)
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,20 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestOpenAIWSTurnPricingZeroValue 钉死 WS turn 定价的零值语义:
|
||||
// 没有 turn 起始回调的 ingress 模式(ws_v2 passthrough 只实现 AfterTurn)
|
||||
// 必须让 pricingAt 保持零,由 RecordUsage 回退到记录时刻。
|
||||
//
|
||||
// 反例(本 PR 引入的回归):用建连时刻初始化,会把透传连接的所有 turn 钉死在
|
||||
// 建连时的高峰因子——客户端峰前一分钟建连并保活,整条连接就按谷价结算。
|
||||
func TestOpenAIWSTurnPricingZeroValue(t *testing.T) {
|
||||
var p openAIWSTurnPricing
|
||||
require.True(t, p.current().IsZero(),
|
||||
"未经 turn 起始回调冻结时必须保持零值,交由 RecordUsage 回退记录时刻")
|
||||
func TestOpenAIWSTurnPricingCurrentOr(t *testing.T) {
|
||||
fallback := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("frozen time takes precedence", func(t *testing.T) {
|
||||
frozen := fallback.Add(time.Minute)
|
||||
var p openAIWSTurnPricing
|
||||
p.freeze(frozen)
|
||||
require.Equal(t, frozen, p.currentOr(fallback))
|
||||
})
|
||||
|
||||
t.Run("zero value falls back to turn start", func(t *testing.T) {
|
||||
var p openAIWSTurnPricing
|
||||
require.Equal(t, fallback, p.currentOr(fallback))
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenAIWSTurnPricingFreezePerTurn 钉死每个 turn 的 BeforeTurn 都会覆盖
|
||||
@@ -27,8 +31,8 @@ func TestOpenAIWSTurnPricingFreezePerTurn(t *testing.T) {
|
||||
turn2 := time.Now()
|
||||
|
||||
p.freeze(turn1)
|
||||
require.Equal(t, turn1, p.current())
|
||||
require.Equal(t, turn1, p.currentOr(time.Time{}))
|
||||
|
||||
p.freeze(turn2)
|
||||
require.Equal(t, turn2, p.current(), "后续 turn 必须使用自己的定价时刻")
|
||||
require.Equal(t, turn2, p.currentOr(time.Time{}), "后续 turn 必须使用自己的定价时刻")
|
||||
}
|
||||
@@ -12,9 +12,40 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type blockingOpsResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
writeStarted chan struct{}
|
||||
writeRelease chan struct{}
|
||||
}
|
||||
|
||||
func (w *blockingOpsResponseWriter) WriteString(s string) (int, error) {
|
||||
close(w.writeStarted)
|
||||
<-w.writeRelease
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
type deterministicOpsCaptureWriterStatePool struct {
|
||||
states []*opsCaptureWriterState
|
||||
}
|
||||
|
||||
func (p *deterministicOpsCaptureWriterStatePool) Get() any {
|
||||
if len(p.states) == 0 {
|
||||
return &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
}
|
||||
last := len(p.states) - 1
|
||||
state := p.states[last]
|
||||
p.states = p.states[:last]
|
||||
return state
|
||||
}
|
||||
|
||||
func (p *deterministicOpsCaptureWriterStatePool) Put(value any) {
|
||||
if state, ok := value.(*opsCaptureWriterState); ok && state != nil {
|
||||
p.states = append(p.states, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_NilInnerWriter_NoPanic(t *testing.T) {
|
||||
w := &opsCaptureWriter{}
|
||||
w.ResponseWriter = nil
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
assert.Equal(t, 0, w.Status())
|
||||
@@ -88,3 +119,86 @@ func TestOpsCaptureWriter_CompactKeepaliveRestoresOriginalWriter(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, outerStatus)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_StaleLeaseCannotReachReacquiredState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
pool := &deterministicOpsCaptureWriterStatePool{}
|
||||
|
||||
firstRecorder := httptest.NewRecorder()
|
||||
firstContext, _ := gin.CreateTestContext(firstRecorder)
|
||||
stale := acquireOpsCaptureWriterFromPool(pool, firstContext.Writer)
|
||||
releaseOpsCaptureWriter(stale)
|
||||
|
||||
secondRecorder := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(secondRecorder)
|
||||
current := acquireOpsCaptureWriterFromPool(pool, secondContext.Writer)
|
||||
defer releaseOpsCaptureWriter(current)
|
||||
require.NotSame(t, stale, current)
|
||||
require.Same(t, stale.state, current.state)
|
||||
|
||||
current.WriteHeader(http.StatusInternalServerError)
|
||||
_, err := current.WriteString("current")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("current"), current.capturedBytes())
|
||||
|
||||
n, err := stale.WriteString("stale")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, n)
|
||||
require.Nil(t, stale.capturedBytes())
|
||||
require.Equal(t, []byte("current"), current.capturedBytes())
|
||||
require.NotContains(t, secondRecorder.Body.String(), "stale")
|
||||
|
||||
// Releasing the stale handle must not return an active state to the pool.
|
||||
releaseOpsCaptureWriter(stale)
|
||||
thirdRecorder := httptest.NewRecorder()
|
||||
thirdContext, _ := gin.CreateTestContext(thirdRecorder)
|
||||
other := acquireOpsCaptureWriterFromPool(pool, thirdContext.Writer)
|
||||
defer releaseOpsCaptureWriter(other)
|
||||
require.NotSame(t, current.state, other.state)
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_ReleaseWaitsForDelegatedWriteWithoutHoldingStateMutex(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
pool := &deterministicOpsCaptureWriterStatePool{}
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
inner := &blockingOpsResponseWriter{
|
||||
ResponseWriter: ctx.Writer,
|
||||
writeStarted: make(chan struct{}),
|
||||
writeRelease: make(chan struct{}),
|
||||
}
|
||||
w := acquireOpsCaptureWriterFromPool(pool, inner)
|
||||
|
||||
writeDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writeDone)
|
||||
_, _ = w.WriteString("body")
|
||||
}()
|
||||
<-inner.writeStarted
|
||||
|
||||
if !w.state.mu.TryLock() {
|
||||
t.Fatal("state mutex remained held across the delegated network write")
|
||||
}
|
||||
w.state.mu.Unlock()
|
||||
|
||||
releaseDone := make(chan struct{})
|
||||
go func() {
|
||||
releaseOpsCaptureWriter(w)
|
||||
close(releaseDone)
|
||||
}()
|
||||
select {
|
||||
case <-releaseDone:
|
||||
t.Fatal("release returned while a delegated write was still active")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
require.Empty(t, pool.states)
|
||||
|
||||
close(inner.writeRelease)
|
||||
<-writeDone
|
||||
select {
|
||||
case <-releaseDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("release did not finish after the delegated write returned")
|
||||
}
|
||||
require.Len(t, pool.states, 1)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -37,15 +38,18 @@ func (r *ingressRejectSettingRepo) Set(context.Context, string, string) error {
|
||||
type ingressRejectOpsRepo struct {
|
||||
service.OpsRepository
|
||||
insertCalls int
|
||||
entries []*service.OpsInsertErrorLogInput
|
||||
}
|
||||
|
||||
func (r *ingressRejectOpsRepo) InsertErrorLog(context.Context, *service.OpsInsertErrorLogInput) (int64, error) {
|
||||
func (r *ingressRejectOpsRepo) InsertErrorLog(_ context.Context, entry *service.OpsInsertErrorLogInput) (int64, error) {
|
||||
r.insertCalls++
|
||||
r.entries = append(r.entries, entry)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(context.Context, []*service.OpsInsertErrorLogInput) (int64, error) {
|
||||
func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(_ context.Context, entries []*service.OpsInsertErrorLogInput) (int64, error) {
|
||||
r.insertCalls++
|
||||
r.entries = append(r.entries, entries...)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -187,21 +191,23 @@ func TestOpsCaptureWriterPool_ResetOnRelease(t *testing.T) {
|
||||
|
||||
writer := acquireOpsCaptureWriter(c.Writer)
|
||||
require.NotNil(t, writer)
|
||||
_, err := writer.buf.WriteString("temp-error-body")
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, err := writer.WriteString("temp-error-body")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, writer.capturedBytes())
|
||||
|
||||
releaseOpsCaptureWriter(writer)
|
||||
|
||||
reused := acquireOpsCaptureWriter(c.Writer)
|
||||
defer releaseOpsCaptureWriter(reused)
|
||||
|
||||
require.Zero(t, reused.buf.Len(), "writer should be reset before reuse")
|
||||
require.Empty(t, reused.capturedBytes(), "writer should be reset before reuse")
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriterPool_DropsLargeBuffers(t *testing.T) {
|
||||
w := &opsCaptureWriter{}
|
||||
w.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1)
|
||||
require.False(t, shouldPoolOpsCaptureWriter(w))
|
||||
state := &opsCaptureWriterState{}
|
||||
state.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1)
|
||||
require.False(t, shouldPoolOpsCaptureWriterState(state))
|
||||
}
|
||||
|
||||
func TestEnqueueOpsErrorLog_SanitizesAndBoundsBodyBeforeQueue(t *testing.T) {
|
||||
@@ -279,6 +285,308 @@ func TestOpsErrorLoggerMiddleware_HardSkipsIngressRejection(t *testing.T) {
|
||||
require.Zero(t, OpsErrorLogEnqueuedTotal(), "ingress rejection must not enter the error queue")
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_DedicatedCyberSessionBlockRecordsExactlyOnce(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 3)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := &OpenAIGatewayHandler{opsService: ops}
|
||||
apiKey := &service.APIKey{ID: 41, Key: "sk-dedicated-test"}
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, "gpt-test", "session-block-hash")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
|
||||
"type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": "blocked",
|
||||
}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "cyber_policy_session_blocked", job.entry.ErrorType)
|
||||
require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_OrdinaryPermissionStillRecords(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
|
||||
"type": "permission_error", "code": "permission_denied", "message": "denied",
|
||||
}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "permission_error", job.entry.ErrorType)
|
||||
require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RecordsRecoveredUpstreamTelemetryOutsideFailureSLA(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &ingressRejectOpsRepo{}
|
||||
ops := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
UpstreamStatusCode: http.StatusTooManyRequests,
|
||||
Message: "earlier attempt was rate limited",
|
||||
}})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed"})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Nil(t, job.entry.UpstreamErrors, "raw attempts must be released before async queueing")
|
||||
require.NotNil(t, job.entry.UpstreamErrorsJSON)
|
||||
queuedEvents, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queuedEvents, 1)
|
||||
require.Equal(t, http.StatusTooManyRequests, queuedEvents[0].UpstreamStatusCode)
|
||||
|
||||
flushOpsErrorLogBatch([]opsErrorLogJob{job})
|
||||
require.Equal(t, 1, repo.insertCalls)
|
||||
require.Len(t, repo.entries, 1)
|
||||
persisted := repo.entries[0]
|
||||
require.Equal(t, http.StatusOK, persisted.StatusCode, "recovered telemetry must remain outside failed-request SLA")
|
||||
require.Equal(t, "upstream", persisted.ErrorPhase)
|
||||
require.Equal(t, "upstream_error", persisted.ErrorType)
|
||||
require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", persisted.ErrorMessage)
|
||||
require.NotNil(t, persisted.UpstreamErrorsJSON)
|
||||
persistedEvents, err := service.ParseOpsUpstreamErrors(*persisted.UpstreamErrorsJSON)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, persistedEvents, 1)
|
||||
require.Equal(t, http.StatusTooManyRequests, persistedEvents[0].UpstreamStatusCode)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RecoveredTelemetryFiltersSkipMonitoringAttempts(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusTooManyRequests, Message: "visible retry"},
|
||||
{UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed"})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "Recovered upstream error 429: visible retry", job.entry.ErrorMessage)
|
||||
require.NotNil(t, job.entry.UpstreamErrorsJSON)
|
||||
events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
require.Equal(t, "visible retry", events[0].Message)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RecoveredTelemetrySkipsAllHiddenAttempts(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
UpstreamStatusCode: http.StatusTooManyRequests,
|
||||
Message: "hidden retry",
|
||||
SkipMonitoring: true,
|
||||
}})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed"})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, int64(0), OpsErrorLogQueueLength())
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_IntermediateSkipMonitoringDoesNotHideFinalVisibleFailure(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
|
||||
{UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
|
||||
})
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "upstream_error", "message": "visible final"}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
|
||||
require.Equal(t, "visible final", job.entry.ErrorMessage)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_CapturesSplitResponsesFailedSSE(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
setOpsRequestContext(c, "gpt-5.5", true)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.Write([]byte("event: response."))
|
||||
_, _ = c.Writer.Write([]byte("failed\n"))
|
||||
_, _ = c.Writer.Write([]byte(`data: {"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Too many pending requests"}}}`))
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
|
||||
require.Equal(t, "rate_limit_error", job.entry.ErrorType)
|
||||
require.Contains(t, job.entry.ErrorMessage, "Too many pending requests")
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_CapturesSplitDataOnlyTerminalMarkers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
suffix string
|
||||
wantType string
|
||||
wantCode string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "response failed with space",
|
||||
prefix: `data: {"type":"response.`,
|
||||
suffix: `failed","response":{"error":{"code":"server_is_overloaded","message":"busy"}}}`,
|
||||
wantType: "overloaded_error",
|
||||
wantCode: "server_is_overloaded",
|
||||
wantError: "busy",
|
||||
},
|
||||
{
|
||||
name: "response failed without space",
|
||||
prefix: `data:{"type":"response.`,
|
||||
suffix: `failed","error":{"code":"rate_limit_exceeded","message":"slow down"}}`,
|
||||
wantType: "rate_limit_error",
|
||||
wantCode: "rate_limit_exceeded",
|
||||
wantError: "slow down",
|
||||
},
|
||||
{
|
||||
name: "error with space",
|
||||
prefix: `data: {"type":"er`,
|
||||
suffix: `ror","error":{"type":"invalid_request_error","code":"invalid_request","message":"bad input"}}`,
|
||||
wantType: "invalid_request_error",
|
||||
wantCode: "invalid_request",
|
||||
wantError: "bad input",
|
||||
},
|
||||
{
|
||||
name: "error without space",
|
||||
prefix: `data:{"type":"er`,
|
||||
suffix: `ror","error":{"type":"authentication_error","code":"authentication_failed","message":"sign in"}}`,
|
||||
wantType: "authentication_error",
|
||||
wantCode: "authentication_failed",
|
||||
wantError: "sign in",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
state.captureResponseChunk([]byte(tt.prefix), http.StatusOK)
|
||||
require.Empty(t, state.buf.Bytes(), "partial frame must remain in the bounded probe")
|
||||
state.captureResponseChunk([]byte(tt.suffix+"\n\n"), http.StatusOK)
|
||||
|
||||
parsed := parseOpsErrorResponse(state.buf.Bytes())
|
||||
require.True(t, state.sseCapturing)
|
||||
require.True(t, parsed.StreamFailure)
|
||||
require.Equal(t, tt.wantType, parsed.ErrorType)
|
||||
require.Equal(t, tt.wantCode, parsed.Code)
|
||||
require.Equal(t, tt.wantError, parsed.Message)
|
||||
require.LessOrEqual(t, len(state.probe), opsTerminalSSEFrameProbeLimit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_StreamFailureUsesTerminalErrorOverAttemptContext(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
service.SetOpsUpstreamError(c, http.StatusBadGateway, "Upstream transport error", "earlier attempt failed")
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.WriteString("event: er")
|
||||
_, _ = c.Writer.WriteString("ror\n")
|
||||
_, _ = c.Writer.WriteString(`data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input exceeds the context window"}}`)
|
||||
_, _ = c.Writer.WriteString("\n\n")
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
|
||||
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
|
||||
require.NotNil(t, job.entry.UpstreamStatusCode)
|
||||
require.Equal(t, http.StatusBadRequest, *job.entry.UpstreamStatusCode)
|
||||
require.NotNil(t, job.entry.UpstreamErrorMessage)
|
||||
require.Equal(t, "input exceeds the context window", *job.entry.UpstreamErrorMessage)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_PrefersContextRequestID(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Header("X-Request-Id", "response-header-id")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "bad input"}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
request = request.WithContext(context.WithValue(request.Context(), ctxkey.RequestID, "context-request-id"))
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "context-request-id", job.entry.RequestID)
|
||||
}
|
||||
|
||||
func TestNormalizeOpsPersistentUserAgentBoundsAndPreservesUTF8(t *testing.T) {
|
||||
value := strings.Repeat("a", opsErrorLogMaxUserAgentBytes-1) + "你" + strings.Repeat("b", 32)
|
||||
got := normalizeOpsPersistentUserAgent(" " + value + " ")
|
||||
@@ -382,6 +690,23 @@ func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) {
|
||||
require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
|
||||
}
|
||||
|
||||
func TestShouldSkipFinalOpsFailureUsesOnlyFinalAttemptRule(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusBadGateway, Message: "hidden intermediate", SkipMonitoring: true},
|
||||
{UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
|
||||
})
|
||||
require.False(t, shouldSkipFinalOpsFailure(c))
|
||||
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusBadGateway, Message: "visible intermediate"},
|
||||
nil,
|
||||
{UpstreamStatusCode: http.StatusServiceUnavailable, Message: "hidden final", SkipMonitoring: true},
|
||||
})
|
||||
require.True(t, shouldSkipFinalOpsFailure(c))
|
||||
}
|
||||
|
||||
// MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。
|
||||
func TestMarkOpsStreamError_FirstWins(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -398,6 +723,35 @@ func TestMarkOpsStreamError_FirstWins(t *testing.T) {
|
||||
require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus)
|
||||
}
|
||||
|
||||
func TestLogOpsStreamError_RecordsOneFailurePerWebSocketTurn(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
|
||||
service.SetOpenAIClientTransport(c, service.OpenAIClientTransportWS)
|
||||
|
||||
service.BeginOpsStreamTurn(c, 1)
|
||||
service.MarkOpsStreamFailure(c, "rate_limit_error", "rate_limit_exceeded", "turn one failed", http.StatusTooManyRequests)
|
||||
service.MarkOpsStreamError(c, "upstream_error", "generic duplicate for turn one", http.StatusBadGateway)
|
||||
service.BeginOpsStreamTurn(c, 2)
|
||||
service.MarkOpsStreamFailure(c, "permission_error", "permission_denied", "turn two failed", http.StatusForbidden)
|
||||
|
||||
streamErrors := service.GetOpsStreamErrors(c)
|
||||
require.Len(t, streamErrors, 2)
|
||||
require.Equal(t, 1, streamErrors[0].Turn)
|
||||
require.Equal(t, 2, streamErrors[1].Turn)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
logOpsStreamError(c, ops, http.StatusSwitchingProtocols)
|
||||
|
||||
require.Equal(t, int64(2), OpsErrorLogQueueLength())
|
||||
first := <-opsErrorLogQueue
|
||||
second := <-opsErrorLogQueue
|
||||
require.Equal(t, "turn one failed", first.entry.ErrorMessage)
|
||||
require.Equal(t, http.StatusTooManyRequests, first.entry.StatusCode)
|
||||
require.Equal(t, "turn two failed", second.entry.ErrorMessage)
|
||||
require.Equal(t, http.StatusForbidden, second.entry.StatusCode)
|
||||
}
|
||||
|
||||
func TestIsKnownOpsErrorType(t *testing.T) {
|
||||
known := []string{
|
||||
"invalid_request_error",
|
||||
@@ -497,6 +851,107 @@ func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t
|
||||
require.Equal(t, "gateway", errorSource)
|
||||
}
|
||||
|
||||
func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
|
||||
c,
|
||||
"model_not_found",
|
||||
"Model \"gpt-missing\" is not supported by any configured account in this group",
|
||||
"",
|
||||
http.StatusNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, "routing", phase)
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "platform", errorOwner)
|
||||
require.Equal(t, "gateway", errorSource)
|
||||
}
|
||||
|
||||
func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
Stage: string(service.GatewayFailureStageAccountAuth),
|
||||
UpstreamStatusCode: http.StatusUnauthorized,
|
||||
}})
|
||||
|
||||
phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound)
|
||||
|
||||
require.Equal(t, "routing", phase)
|
||||
require.True(t, limited)
|
||||
require.Equal(t, "platform", owner)
|
||||
require.Equal(t, "gateway", source)
|
||||
}
|
||||
|
||||
func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway)
|
||||
|
||||
phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway)
|
||||
|
||||
require.Equal(t, "upstream", phase)
|
||||
require.False(t, limited)
|
||||
require.Equal(t, "provider", owner)
|
||||
require.Equal(t, "upstream_http", source)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 1)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/chat/completions", func(c *gin.Context) {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
c.Set(opsAccountIDKey, int64(99))
|
||||
c.Set(opsUpstreamModelKey, "stale-upstream-model")
|
||||
setActualUpstreamEndpoint(c, "/v1/chat/completions")
|
||||
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
|
||||
c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error")
|
||||
c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
Stage: string(service.GatewayFailureStageAccountAuth),
|
||||
UpstreamStatusCode: http.StatusUnauthorized,
|
||||
Message: "stale auth failure",
|
||||
}})
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "model_not_found",
|
||||
"message": "Model \"gpt-missing\" is not supported by any configured account in this group",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusNotFound, job.entry.StatusCode)
|
||||
require.Equal(t, "routing", job.entry.ErrorPhase)
|
||||
require.True(t, job.entry.IsBusinessLimited)
|
||||
require.Equal(t, "platform", job.entry.ErrorOwner)
|
||||
require.Equal(t, "gateway", job.entry.ErrorSource)
|
||||
require.Nil(t, job.entry.AccountID)
|
||||
require.Nil(t, job.entry.UpstreamStatusCode)
|
||||
require.Nil(t, job.entry.UpstreamErrors)
|
||||
require.Nil(t, job.entry.UpstreamErrorMessage)
|
||||
require.Nil(t, job.entry.UpstreamErrorDetail)
|
||||
require.Empty(t, job.entry.UpstreamModel)
|
||||
require.Empty(t, job.entry.UpstreamEndpoint)
|
||||
}
|
||||
|
||||
func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -621,7 +1076,11 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
|
||||
errType := normalizeOpsErrorType(tt.errType, tt.code)
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status)
|
||||
|
||||
require.Equal(t, "api_error", errType)
|
||||
wantErrType := "api_error"
|
||||
if tt.errType == "permission_error" {
|
||||
wantErrType = "permission_error"
|
||||
}
|
||||
require.Equal(t, wantErrType, errType)
|
||||
require.Equal(t, "auth", phase)
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "client", errorOwner)
|
||||
@@ -772,7 +1231,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
message: "This group is restricted to Claude Code clients (/v1/messages only)",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
@@ -781,7 +1240,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
message: "Image generation is not enabled for this group",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
@@ -808,7 +1267,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
message: "model claude-3-5-sonnet not in whitelist",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
@@ -835,7 +1294,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
message: "openai service_tier=priority is not allowed for model gpt-5.5",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
@@ -1146,6 +1605,254 @@ func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) {
|
||||
require.Equal(t, "API Key 所属分组已删除", parsed.Message)
|
||||
}
|
||||
|
||||
func TestParseOpsErrorResponsePreservesStructuredTopLevelSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantType string
|
||||
wantCode string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "model not found",
|
||||
body: `{"type":"model_not_found","code":404,"message":"model unavailable"}`,
|
||||
wantType: "model_not_found",
|
||||
wantCode: "404",
|
||||
wantMsg: "model unavailable",
|
||||
},
|
||||
{
|
||||
name: "string error",
|
||||
body: `{"type":"service_unavailable","code":"temporarily_unavailable","error":"capacity exhausted"}`,
|
||||
wantType: "service_unavailable",
|
||||
wantCode: "temporarily_unavailable",
|
||||
wantMsg: "capacity exhausted",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed := parseOpsErrorResponse([]byte(tt.body))
|
||||
require.Equal(t, tt.wantType, normalizeOpsErrorType(parsed.ErrorType, parsed.Code))
|
||||
require.Equal(t, tt.wantCode, parsed.Code)
|
||||
require.Equal(t, tt.wantMsg, parsed.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpsUpstreamFieldsUsesLastNonNilAttempt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.SetOpsUpstreamError(c, http.StatusUnauthorized, "stale context", "stale detail")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusTooManyRequests, Message: "first attempt", Detail: "first detail"},
|
||||
nil,
|
||||
{UpstreamStatusCode: http.StatusServiceUnavailable, Message: "final attempt", Detail: "final detail"},
|
||||
nil,
|
||||
})
|
||||
entry := &service.OpsInsertErrorLogInput{}
|
||||
|
||||
applyOpsUpstreamFieldsFromContext(c, entry)
|
||||
|
||||
require.NotNil(t, entry.UpstreamStatusCode)
|
||||
require.Equal(t, http.StatusServiceUnavailable, *entry.UpstreamStatusCode)
|
||||
require.NotNil(t, entry.UpstreamErrorMessage)
|
||||
require.Equal(t, "final attempt", *entry.UpstreamErrorMessage)
|
||||
require.NotNil(t, entry.UpstreamErrorDetail)
|
||||
require.Equal(t, "final detail", *entry.UpstreamErrorDetail)
|
||||
require.Len(t, entry.UpstreamErrors, 4)
|
||||
}
|
||||
|
||||
func TestApplyOpsUpstreamFieldsFinalStatuslessAttemptClearsStaleContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.SetOpsUpstreamError(c, http.StatusBadGateway, "stale response", "stale body")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
|
||||
{UpstreamStatusCode: http.StatusBadGateway, Message: "first response"},
|
||||
{Kind: "request_error", Message: "final transport failure", Detail: "connection reset"},
|
||||
})
|
||||
entry := &service.OpsInsertErrorLogInput{}
|
||||
|
||||
applyOpsUpstreamFieldsFromContext(c, entry)
|
||||
|
||||
require.Nil(t, entry.UpstreamStatusCode)
|
||||
require.NotNil(t, entry.UpstreamErrorMessage)
|
||||
require.Equal(t, "final transport failure", *entry.UpstreamErrorMessage)
|
||||
require.NotNil(t, entry.UpstreamErrorDetail)
|
||||
require.Equal(t, "connection reset", *entry.UpstreamErrorDetail)
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_ProtocolLevelTerminalFrameDetection(t *testing.T) {
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
chunks := []string{
|
||||
"event : response.failed\r\n",
|
||||
"data: { \"response\" : { \"error\" : { \"message\" : \"busy\", \"code\" : \"service_unavailable\" } },",
|
||||
" \"type\" : \"response.failed\" }\r\n\r\n",
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
state.captureResponseChunk([]byte(chunk), http.StatusOK)
|
||||
}
|
||||
|
||||
parsed := parseOpsErrorResponse(state.buf.Bytes())
|
||||
require.True(t, state.sseCapturing)
|
||||
require.True(t, parsed.StreamFailure)
|
||||
require.Equal(t, "service_unavailable_error", parsed.ErrorType)
|
||||
require.Equal(t, "service_unavailable", parsed.Code)
|
||||
require.Equal(t, "busy", parsed.Message)
|
||||
require.Equal(t, http.StatusServiceUnavailable, inferStreamFailureStatus(nil, parsed))
|
||||
}
|
||||
|
||||
func TestParseOpsSSEFailure_TopLevelErrorsAndUnknownStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantType string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "top-level permission",
|
||||
body: "event: error\ndata: {\"message\":\"denied\",\"code\":\"permission_denied\",\"type\":\"error\"}\n\n",
|
||||
wantType: "permission_error",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "top-level unavailable",
|
||||
body: "data: {\"message\":\"busy\",\"type\":\"error\",\"code\":\"service_unavailable\"}\n\n",
|
||||
wantType: "service_unavailable_error",
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "unknown terminal",
|
||||
body: "event: response.failed\ndata: {\"type\":\"response.failed\",\"error\":{\"code\":\"new_provider_code\",\"message\":\"failed\"}}\n\n",
|
||||
wantType: "upstream_error",
|
||||
wantStatus: http.StatusBadGateway,
|
||||
},
|
||||
{
|
||||
name: "explicit terminal status",
|
||||
body: "event: error\ndata: {\"type\":\"error\",\"status_code\":429,\"code\":\"new_rate_code\",\"message\":\"slow down\"}\n\n",
|
||||
wantType: "api_error",
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed := parseOpsErrorResponse([]byte(tt.body))
|
||||
require.True(t, parsed.StreamFailure)
|
||||
require.Equal(t, tt.wantType, parsed.ErrorType)
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.SetOpsUpstreamError(c, http.StatusUnauthorized, "old attempt", "")
|
||||
require.Equal(t, tt.wantStatus, inferStreamFailureStatus(c, parsed), "terminal status must not inherit an earlier attempt")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_OversizedNonTerminalFrameRemainsBounded(t *testing.T) {
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
state.captureResponseChunk([]byte("data: "+strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2)+"\n\n"), http.StatusOK)
|
||||
require.Empty(t, state.buf.Bytes())
|
||||
require.LessOrEqual(t, cap(state.probe), opsTerminalSSEFrameProbeLimit)
|
||||
|
||||
state.captureResponseChunk([]byte("event: error\ndata: {\"type\":\"error\",\"code\":\"permission_denied\",\"message\":\"denied\"}\n\n"), http.StatusOK)
|
||||
require.True(t, state.sseCapturing)
|
||||
require.NotEmpty(t, state.buf.Bytes())
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_TerminalMetadataSurvivesBodyCaptureTruncation(t *testing.T) {
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
frame := "event: response.failed\ndata: {\"padding\":\"" + strings.Repeat("x", opsCaptureWriterLimit) + "\",\"type\":\"response.failed\",\"error\":{\"code\":\"service_unavailable\",\"message\":\"busy\"}}\n\n"
|
||||
state.captureResponseChunk([]byte(frame), http.StatusOK)
|
||||
state.finalizeResponseCapture()
|
||||
|
||||
require.Len(t, state.buf.Bytes(), opsCaptureWriterLimit)
|
||||
require.True(t, parseOpsErrorResponse(state.buf.Bytes()).StreamFailure, "the bounded parser must fail closed from the terminal event line")
|
||||
require.True(t, state.terminalFound)
|
||||
require.Equal(t, "service_unavailable_error", state.terminalError.ErrorType)
|
||||
require.Equal(t, "busy", state.terminalError.Message)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_LargeTerminalFrameUsesEventFallback(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.WriteString("event: response.failed\n")
|
||||
_, _ = c.Writer.WriteString("data: {\"authorization\":\"Bearer must-not-persist\",\"padding\":\"" + strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2) + "\"}")
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusBadGateway, job.entry.StatusCode)
|
||||
require.Equal(t, "upstream_error", job.entry.ErrorType)
|
||||
require.Equal(t, "upstream stream failed", job.entry.ErrorMessage)
|
||||
require.NotContains(t, job.entry.ErrorMessage, "must-not-persist")
|
||||
require.NotContains(t, job.entry.ErrorBody, "must-not-persist")
|
||||
require.Contains(t, job.entry.ErrorBody, `"payload_truncated":true`)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_DetectsTerminalDataAtEOFWithoutBlankLine(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.WriteString(`data: {"message":"denied","code":"permission_denied","type":"error"}`)
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
|
||||
require.Equal(t, "permission_error", job.entry.ErrorType)
|
||||
require.Equal(t, "denied", job.entry.ErrorMessage)
|
||||
}
|
||||
|
||||
func TestOpsCaptureWriter_DetectsCROnlySSEFrame(t *testing.T) {
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
state.captureResponseChunk([]byte("data: {\"type\":\"error\",\"code\":\"service_unavailable\",\"message\":\"busy\"}\r\r"), http.StatusOK)
|
||||
|
||||
require.True(t, state.sseCapturing)
|
||||
parsed := parseOpsErrorResponse(state.buf.Bytes())
|
||||
require.True(t, parsed.StreamFailure)
|
||||
require.Equal(t, "service_unavailable_error", parsed.ErrorType)
|
||||
}
|
||||
|
||||
func TestSanitizeOpsSSEDataForPersistence_RedactsJSONFields(t *testing.T) {
|
||||
body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer secret\",\ndata: \"nested\":{\"api_key\":\"sk-secret\"}}\n\n")
|
||||
sanitized := sanitizeOpsSSEDataForPersistence(body)
|
||||
require.NotContains(t, sanitized, "Bearer secret")
|
||||
require.NotContains(t, sanitized, "sk-secret")
|
||||
require.Contains(t, sanitized, `"authorization":"[REDACTED]"`)
|
||||
require.Contains(t, sanitized, `"api_key":"[REDACTED]"`)
|
||||
}
|
||||
|
||||
func TestSanitizeOpsSSEDataForPersistence_DropsTruncatedJSONFragment(t *testing.T) {
|
||||
body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer leaked")
|
||||
sanitized := sanitizeOpsSSEDataForPersistence(body)
|
||||
require.NotContains(t, sanitized, "Bearer leaked")
|
||||
require.Contains(t, sanitized, `data: {"payload_truncated":true}`)
|
||||
}
|
||||
|
||||
func BenchmarkOpsCaptureWriterSuccessfulSSEFrames(b *testing.B) {
|
||||
frame := []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n")
|
||||
state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
state.captureResponseChunk(frame, http.StatusOK)
|
||||
}
|
||||
if state.buf.Len() != 0 {
|
||||
b.Fatal("successful frames must not be captured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -15,11 +15,11 @@ func TestOpsCaptureWriterDoesNotCopyIngressRejectBody(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
writer := acquireOpsCaptureWriter(context.Writer)
|
||||
defer releaseOpsCaptureWriter(writer)
|
||||
writer.ctx = context
|
||||
writer.setContext(context)
|
||||
context.Writer = writer
|
||||
middleware2.MarkIngressRejected(context, middleware2.IngressRejectInvalidAPIKey)
|
||||
context.Status(http.StatusUnauthorized)
|
||||
_, err := context.Writer.WriteString(`{"code":"INVALID_API_KEY","message":"Invalid API key"}`)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, writer.buf.Len())
|
||||
require.Empty(t, writer.capturedBytes())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// logRequestBodyReadFailure records a bounded, payload-free reason for a body
|
||||
// read failure. Clients continue to receive the stable generic error message;
|
||||
// operators get enough information to distinguish compression failures from a
|
||||
// disconnected/truncated upload without logging request content.
|
||||
func logRequestBodyReadFailure(reqLog *zap.Logger, req *http.Request, err error) {
|
||||
if reqLog == nil || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
contentLength := int64(-1)
|
||||
contentEncoding := "identity"
|
||||
if req != nil {
|
||||
contentLength = req.ContentLength
|
||||
contentEncoding = requestContentEncodingCategory(req.Header.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
reqLog.Warn("read request body failed",
|
||||
zap.String("error_kind", requestBodyReadErrorKind(err)),
|
||||
zap.String("content_encoding", contentEncoding),
|
||||
zap.Int64("content_length", contentLength),
|
||||
)
|
||||
}
|
||||
|
||||
func requestContentEncodingCategory(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "identity":
|
||||
return "identity"
|
||||
case "gzip", "x-gzip":
|
||||
return "gzip"
|
||||
case "zstd":
|
||||
return "zstd"
|
||||
case "deflate":
|
||||
return "deflate"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
func requestBodyReadErrorKind(err error) string {
|
||||
if err == nil {
|
||||
return "none"
|
||||
}
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return "max_bytes"
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
if strings.Contains(lower, "decode content-encoding") {
|
||||
if strings.Contains(lower, "unsupported content-encoding") {
|
||||
return "unsupported_content_encoding"
|
||||
}
|
||||
return "decode_content_encoding"
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) {
|
||||
return "client_disconnect"
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return "truncated_body"
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return "transport"
|
||||
}
|
||||
return "io_read"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogRequestBodyReadFailureClassifiesWithoutPayload(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("secret-payload-marker"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
req.ContentLength = 21
|
||||
|
||||
logRequestBodyReadFailure(log, req, errors.New(`decode Content-Encoding "gzip": unexpected EOF secret-payload-marker`))
|
||||
|
||||
entries := logs.All()
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "read request body failed", entries[0].Message)
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, "decode_content_encoding", fields["error_kind"])
|
||||
require.Equal(t, "gzip", fields["content_encoding"])
|
||||
require.EqualValues(t, 21, fields["content_length"])
|
||||
require.NotContains(t, entries[0].Message+fmt.Sprint(fields), "secret-payload-marker")
|
||||
}
|
||||
|
||||
func TestRequestBodyReadErrorKind(t *testing.T) {
|
||||
require.Equal(t, "unsupported_content_encoding", requestBodyReadErrorKind(errors.New(`decode Content-Encoding "br": unsupported Content-Encoding`)))
|
||||
require.Equal(t, "truncated_body", requestBodyReadErrorKind(io.ErrUnexpectedEOF))
|
||||
require.Equal(t, "max_bytes", requestBodyReadErrorKind(&http.MaxBytesError{Limit: 10}))
|
||||
require.Equal(t, "other", requestContentEncodingCategory("private-payload-marker"))
|
||||
}
|
||||
@@ -107,11 +107,13 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorMode: settings.ChannelMonitorMode,
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -121,9 +121,11 @@ func (h *UserMsgQueueHelper) waitForLockWithPing(
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
*streamStarted = true
|
||||
}
|
||||
if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil {
|
||||
written, err := fmt.Fprint(c.Writer, string(h.pingFormat))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recordGatewayStreamHeartbeat(c, written)
|
||||
flusher.Flush()
|
||||
|
||||
case <-timer.C:
|
||||
@@ -226,9 +228,11 @@ func (h *UserMsgQueueHelper) ThrottleWithPing(
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
*streamStarted = true
|
||||
}
|
||||
if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil {
|
||||
written, err := fmt.Fprint(c.Writer, string(h.pingFormat))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordGatewayStreamHeartbeat(c, written)
|
||||
flusher.Flush()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
|
||||
@@ -23,6 +23,7 @@ func ProvideAdminHandlers(
|
||||
geminiOAuthHandler *admin.GeminiOAuthHandler,
|
||||
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
|
||||
grokOAuthHandler *admin.GrokOAuthHandler,
|
||||
cnProviderHandler *admin.CNProviderHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
promoHandler *admin.PromoHandler,
|
||||
@@ -34,6 +35,7 @@ func ProvideAdminHandlers(
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
pluginHandler *admin.PluginHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
channelHandler *admin.ChannelHandler,
|
||||
@@ -63,6 +65,7 @@ func ProvideAdminHandlers(
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
GrokOAuth: grokOAuthHandler,
|
||||
CNProvider: cnProviderHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
@@ -74,6 +77,7 @@ func ProvideAdminHandlers(
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
Plugin: pluginHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Channel: channelHandler,
|
||||
@@ -115,6 +119,7 @@ func ProvideGatewayHandler(
|
||||
|
||||
func ProvideOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
pluginManager *service.PluginManager,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
@@ -126,6 +131,7 @@ func ProvideOpenAIGatewayHandler(
|
||||
cfg *config.Config,
|
||||
coordinator *securityaudit.Coordinator,
|
||||
) *OpenAIGatewayHandler {
|
||||
gatewayService.SetPluginManager(pluginManager)
|
||||
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
|
||||
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
|
||||
h.securityAuditCoordinator = coordinator
|
||||
@@ -190,6 +196,7 @@ func ProvideHandlers(
|
||||
batchImageHandler *BatchImageHandler,
|
||||
_ *service.IdempotencyCoordinator,
|
||||
_ *service.IdempotencyCleanupService,
|
||||
_ *service.OpenAIQuotaAutoResetService,
|
||||
) *Handlers {
|
||||
return &Handlers{
|
||||
Auth: authHandler,
|
||||
@@ -253,6 +260,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewGeminiOAuthHandler,
|
||||
admin.NewAntigravityOAuthHandler,
|
||||
admin.NewGrokOAuthHandler,
|
||||
admin.NewCNProviderHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewPromoHandler,
|
||||
@@ -264,6 +272,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewPluginHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewChannelHandler,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Package model 定义服务层使用的数据模型。
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// ErrorPassthroughRule 全局错误透传规则
|
||||
// 用于控制上游错误如何返回给客户端
|
||||
@@ -32,16 +36,28 @@ const MatchModeAll = "all"
|
||||
|
||||
// 支持的平台常量
|
||||
const (
|
||||
PlatformAnthropic = "anthropic"
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
PlatformAnthropic = domain.PlatformAnthropic
|
||||
PlatformOpenAI = domain.PlatformOpenAI
|
||||
PlatformGemini = domain.PlatformGemini
|
||||
PlatformAntigravity = domain.PlatformAntigravity
|
||||
PlatformGrok = domain.PlatformGrok
|
||||
PlatformKimi = domain.PlatformKimi
|
||||
PlatformZhipu = domain.PlatformZhipu
|
||||
PlatformDeepseek = domain.PlatformDeepseek
|
||||
)
|
||||
|
||||
// AllPlatforms 返回所有支持的平台列表
|
||||
func AllPlatforms() []string {
|
||||
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok}
|
||||
return []string{
|
||||
PlatformAnthropic,
|
||||
PlatformOpenAI,
|
||||
PlatformGemini,
|
||||
PlatformAntigravity,
|
||||
PlatformGrok,
|
||||
PlatformKimi,
|
||||
PlatformZhipu,
|
||||
PlatformDeepseek,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 验证规则配置的有效性
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
|
||||
require.ElementsMatch(t, []string{
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"grok",
|
||||
"kimi",
|
||||
"zhipu",
|
||||
"deepseek",
|
||||
}, AllPlatforms())
|
||||
}
|
||||
@@ -255,8 +255,6 @@ const (
|
||||
proxyTLSHandshakeTimeout = 5 * time.Second
|
||||
// clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body)
|
||||
clientTimeout = 10 * time.Second
|
||||
// fetchAvailableModelsBodyLimit limits model-list responses to avoid unbounded memory use.
|
||||
fetchAvailableModelsBodyLimit int64 = 8 << 20
|
||||
)
|
||||
|
||||
func NewClient(proxyURL string) (*Client, error) {
|
||||
@@ -656,10 +654,13 @@ type FetchAvailableModelsResponse struct {
|
||||
|
||||
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
|
||||
// 支持 URL fallback:sandbox → daily → prod
|
||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, nil, errors.New("antigravity client is not configured")
|
||||
}
|
||||
if bodyLimit <= 0 {
|
||||
return nil, nil, errors.New("fetchAvailableModels body limit must be positive")
|
||||
}
|
||||
|
||||
reqBody := FetchAvailableModelsRequest{Project: projectID}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
@@ -693,13 +694,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, fetchAvailableModelsBodyLimit+1))
|
||||
respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
_ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
if int64(len(respBodyBytes)) > fetchAvailableModelsBodyLimit {
|
||||
return nil, nil, fmt.Errorf("响应超过 %d 字节", fetchAvailableModelsBodyLimit)
|
||||
if int64(len(respBodyBytes)) > bodyLimit {
|
||||
return nil, nil, fmt.Errorf("响应超过 %d 字节", bodyLimit)
|
||||
}
|
||||
|
||||
// 检查是否需要 URL 降级
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultFetchAvailableModelsBodyLimit int64 = 8 << 20
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewAPIRequestWithURL
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1517,7 +1519,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 失败: %v", err)
|
||||
}
|
||||
@@ -1562,6 +1564,34 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_RejectsNonPositiveBodyLimit(t *testing.T) {
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "body limit must be positive") {
|
||||
t.Fatalf("非正读取上限应返回调用错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_UsesConfiguredBodyLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"models":{"model-a":{}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 8)
|
||||
if err == nil {
|
||||
t.Fatal("响应超过配置上限时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "响应超过 8 字节") {
|
||||
t.Fatalf("错误应包含配置上限: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@@ -1572,7 +1602,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("服务器返回 403 时应返回错误")
|
||||
}
|
||||
@@ -1592,7 +1622,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("无效 JSON 响应应返回错误")
|
||||
}
|
||||
@@ -1622,7 +1652,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err)
|
||||
}
|
||||
@@ -1650,7 +1680,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("所有 URL 都失败时应返回错误")
|
||||
}
|
||||
@@ -1669,7 +1699,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _, err := client.FetchAvailableModels(ctx, "token", "proj")
|
||||
_, _, err := client.FetchAvailableModels(ctx, "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err == nil {
|
||||
t.Fatal("context 取消时应返回错误")
|
||||
}
|
||||
@@ -1686,7 +1716,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 失败: %v", err)
|
||||
}
|
||||
@@ -1748,7 +1778,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
|
||||
withMockBaseURLs(t, []string{server1.URL, server2.URL})
|
||||
|
||||
client := mustNewClient(t, "")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
|
||||
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ type GeminiImageSearch struct {
|
||||
|
||||
// GeminiToolConfig Gemini 工具配置
|
||||
type GeminiToolConfig struct {
|
||||
FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||
FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
|
||||
}
|
||||
|
||||
// GeminiFunctionCallingConfig 函数调用配置
|
||||
|
||||
@@ -54,7 +54,7 @@ const (
|
||||
|
||||
// Antigravity API 端点
|
||||
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
|
||||
)
|
||||
|
||||
var userAgentVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
|
||||
@@ -94,6 +94,9 @@ func TestForwardBaseURLs_Daily优先(t *testing.T) {
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("ForwardBaseURLs 返回空列表")
|
||||
}
|
||||
if antigravityDailyBaseURL != "https://daily-cloudcode-pa.googleapis.com" {
|
||||
t.Fatalf("daily URL 未与官方客户端对齐: got %s", antigravityDailyBaseURL)
|
||||
}
|
||||
|
||||
// daily URL 应排在第一位
|
||||
if urls[0] != antigravityDailyBaseURL {
|
||||
|
||||
@@ -156,6 +156,13 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
Mode: "VALIDATED",
|
||||
},
|
||||
}
|
||||
// 内置工具(googleSearch)与函数调用混用时,上游要求显式开启
|
||||
// includeServerSideToolInvocations,否则返回 400(issue #5709)。
|
||||
// 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。
|
||||
if hasMixedToolInvocations(tools) {
|
||||
enabled := true
|
||||
innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled
|
||||
}
|
||||
}
|
||||
|
||||
if systemInstruction != nil {
|
||||
@@ -703,6 +710,21 @@ func isWebSearchTool(tool ClaudeTool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具
|
||||
// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。
|
||||
func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool {
|
||||
hasFunc, hasBuiltin := false, false
|
||||
for _, d := range declarations {
|
||||
if len(d.FunctionDeclarations) > 0 {
|
||||
hasFunc = true
|
||||
}
|
||||
if d.GoogleSearch != nil {
|
||||
hasBuiltin = true
|
||||
}
|
||||
}
|
||||
return hasFunc && hasBuiltin
|
||||
}
|
||||
|
||||
// buildTools 构建 tools
|
||||
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
||||
if len(tools) == 0 {
|
||||
|
||||
@@ -565,3 +565,59 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions
|
||||
require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name)
|
||||
require.NotNil(t, req.Request.Tools[1].GoogleSearch)
|
||||
}
|
||||
|
||||
func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
|
||||
functionTool := ClaudeTool{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather information",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}
|
||||
webSearchTool := ClaudeTool{
|
||||
Type: "web_search_20250305",
|
||||
Name: "web_search",
|
||||
}
|
||||
|
||||
transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) {
|
||||
t.Helper()
|
||||
body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{
|
||||
Model: "claude-3-5-sonnet-latest",
|
||||
Messages: []ClaudeMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: json.RawMessage(`[{"type":"text","text":"hello"}]`),
|
||||
},
|
||||
},
|
||||
Tools: tools,
|
||||
}, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
var req V1InternalRequest
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
return req, string(body)
|
||||
}
|
||||
|
||||
t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool})
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.Contains(t, raw, `"includeServerSideToolInvocations":true`)
|
||||
})
|
||||
|
||||
t.Run("function tools only leave the flag unset", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{functionTool})
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.NotContains(t, raw, "includeServerSideToolInvocations")
|
||||
})
|
||||
|
||||
t.Run("web search only leaves the flag unset", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{webSearchTool})
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.NotContains(t, raw, "includeServerSideToolInvocations")
|
||||
})
|
||||
}
|
||||
@@ -674,7 +674,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) {
|
||||
direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
|
||||
|
||||
// Double-conversion bridge
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil)
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
|
||||
double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
|
||||
|
||||
// Compare key fields
|
||||
@@ -988,7 +988,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *te
|
||||
|
||||
direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
|
||||
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil)
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
|
||||
double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
|
||||
|
||||
require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens)
|
||||
@@ -1058,7 +1058,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testin
|
||||
|
||||
direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
|
||||
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil)
|
||||
responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
|
||||
double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
|
||||
|
||||
require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason))
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) {
|
||||
func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) {
|
||||
var response ChatCompletionsResponse
|
||||
readIssue5302Fixture(t, "nonstream_reasoning.json", &response)
|
||||
nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil)
|
||||
nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, nil, false, nil)
|
||||
require.Len(t, nonStream.Output, 2)
|
||||
require.Equal(t, "reasoning", nonStream.Output[0].Type)
|
||||
require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text)
|
||||
|
||||
@@ -17,15 +17,37 @@ const (
|
||||
|
||||
type toolOutputMediaByCallID map[string][]ChatContentPart
|
||||
|
||||
// ResponsesToChatOptions carries optional hooks for
|
||||
// ResponsesToChatCompletionsRequestWithOptions. All fields are optional; a nil
|
||||
// *ResponsesToChatOptions behaves exactly like ResponsesToChatCompletionsRequest.
|
||||
type ResponsesToChatOptions struct {
|
||||
// ReasoningContentByID looks up the cached reasoning text for a reasoning
|
||||
// item id. Codex histories may carry reasoning items with no plaintext
|
||||
// summary (empty summary + opaque encrypted_content, e.g. after remote
|
||||
// compaction); DeepSeek's thinking mode rejects such histories with 400
|
||||
// "The `reasoning_content` in the thinking mode must be passed back to the
|
||||
// API". The gateway caches the reasoning text it streamed under the item
|
||||
// id, so the lookup restores the reasoning_content the client can no
|
||||
// longer provide. Return "" on a miss. A nil lookup keeps the original
|
||||
// behavior.
|
||||
ReasoningContentByID func(itemID string) string
|
||||
}
|
||||
|
||||
// ResponsesToChatCompletionsRequest converts a Responses API request into a
|
||||
// Chat Completions request for upstreams that only implement
|
||||
// /v1/chat/completions.
|
||||
func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsRequest, error) {
|
||||
return ResponsesToChatCompletionsRequestWithOptions(req, nil)
|
||||
}
|
||||
|
||||
// ResponsesToChatCompletionsRequestWithOptions is ResponsesToChatCompletionsRequest
|
||||
// with optional hooks (see ResponsesToChatOptions).
|
||||
func ResponsesToChatCompletionsRequestWithOptions(req *ResponsesRequest, opts *ResponsesToChatOptions) (*ChatCompletionsRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("responses request is nil")
|
||||
}
|
||||
|
||||
messages, err := responsesInputToChatMessages(req.Instructions, req.Input)
|
||||
messages, err := responsesInputToChatMessagesWithOptions(req.Instructions, req.Input, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,6 +159,20 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// FunctionToolNames collects explicitly declared top-level function tools.
|
||||
func FunctionToolNames(tools []ResponsesTool) map[string]bool {
|
||||
var out map[string]bool
|
||||
for _, tool := range tools {
|
||||
if tool.Type == "function" && tool.Name != "" {
|
||||
if out == nil {
|
||||
out = make(map[string]bool)
|
||||
}
|
||||
out[tool.Name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。
|
||||
type NamespacedToolName struct {
|
||||
Namespace string
|
||||
@@ -175,6 +211,43 @@ func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName {
|
||||
return out
|
||||
}
|
||||
|
||||
// customToolCallName restores both the exact downgraded custom-tool name and
|
||||
// namespace-prefixed aliases that chat models sometimes infer from neighboring
|
||||
// flattened namespace tools (for example functions__exec beside
|
||||
// functions__wait). A real declared namespace child always owns its flattened
|
||||
// name, and ambiguous aliases are left as ordinary function calls.
|
||||
func customToolCallName(name string, customTools, functionTools map[string]bool, namespaceTools map[string]NamespacedToolName) (string, bool) {
|
||||
if functionTools[name] {
|
||||
return "", false
|
||||
}
|
||||
if customTools[name] {
|
||||
return name, true
|
||||
}
|
||||
if _, ok := namespaceTools[name]; ok {
|
||||
return "", false
|
||||
}
|
||||
match := ""
|
||||
for customName := range customTools {
|
||||
for _, namespaceTool := range namespaceTools {
|
||||
if flattenNamespaceToolName(namespaceTool.Namespace, customName) != name {
|
||||
continue
|
||||
}
|
||||
if match != "" && match != customName {
|
||||
return "", false
|
||||
}
|
||||
match = customName
|
||||
}
|
||||
}
|
||||
return match, match != ""
|
||||
}
|
||||
|
||||
func customNameForStreamTool(state *ChatCompletionsToResponsesStreamState, name string) string {
|
||||
if customName, ok := customToolCallName(name, state.CustomTools, state.FunctionTools, state.NamespaceTools); ok {
|
||||
return customName
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥
|
||||
// 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型
|
||||
// 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配
|
||||
@@ -202,6 +275,12 @@ func HasToolSearchTool(tools []ResponsesTool) bool {
|
||||
// scattered across per-item cases, and makes unknown future codex item types
|
||||
// fail safe instead of leaking into the upstream request.
|
||||
func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) ([]ChatMessage, error) {
|
||||
return responsesInputToChatMessagesWithOptions(instructions, inputRaw, nil)
|
||||
}
|
||||
|
||||
// responsesInputToChatMessagesWithOptions is responsesInputToChatMessages with
|
||||
// optional hooks (see ResponsesToChatOptions).
|
||||
func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, error) {
|
||||
var messages []ChatMessage
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
content, _ := json.Marshal(instructions)
|
||||
@@ -226,7 +305,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage)
|
||||
return nil, fmt.Errorf("parse responses input: %w", err)
|
||||
}
|
||||
|
||||
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems)
|
||||
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -235,7 +314,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage)
|
||||
|
||||
// buildChatMessagesFromItems walks the Responses input items and appends the
|
||||
// corresponding Chat messages.
|
||||
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, toolOutputMediaByCallID, error) {
|
||||
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, toolOutputMediaByCallID, error) {
|
||||
// pendingReasoning holds the reasoning text from a reasoning item until the
|
||||
// assistant message it belongs to is emitted. DeepSeek's thinking mode
|
||||
// requires the reasoning_content that produced a tool call to be passed back
|
||||
@@ -243,7 +322,23 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
// across an assistant message (so a following tool call in the same turn
|
||||
// still receives it); any other role ends the thinking span.
|
||||
var pendingReasoning string
|
||||
// lastTurnReasoning is the most recent reasoning text of the current turn,
|
||||
// surviving tool outputs. DeepSeek emits reasoning only once per turn, so
|
||||
// chained tool calls (reasoning → call A → output A → call B) leave call B's
|
||||
// assistant message without reasoning_content and DeepSeek 400s the history;
|
||||
// replaying the turn's reasoning on B's message satisfies the contract. Only
|
||||
// a user-side item ends the turn and clears it.
|
||||
var lastTurnReasoning string
|
||||
mediaByCallID := make(toolOutputMediaByCallID)
|
||||
invalidFunctionCallIDs := make(map[string]struct{})
|
||||
invalidEmptyFunctionCallOutputs := 0
|
||||
|
||||
reasoningForAssistant := func() string {
|
||||
if pendingReasoning != "" {
|
||||
return pendingReasoning
|
||||
}
|
||||
return lastTurnReasoning
|
||||
}
|
||||
|
||||
for _, raw := range rawItems {
|
||||
raw = bytesTrimSpace(raw)
|
||||
@@ -258,6 +353,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
content, _ := json.Marshal(text)
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
pendingReasoning = ""
|
||||
lastTurnReasoning = ""
|
||||
continue
|
||||
}
|
||||
return nil, nil, fmt.Errorf("parse responses input item: %w", err)
|
||||
@@ -269,6 +365,18 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
case "reasoning":
|
||||
if txt := extractResponsesReasoningText(item); txt != "" {
|
||||
pendingReasoning = txt
|
||||
} else if opts != nil && opts.ReasoningContentByID != nil {
|
||||
// No plaintext summary (encrypted-only reasoning, e.g. after codex
|
||||
// remote compaction): fall back to the gateway-side cache keyed
|
||||
// by the reasoning item id, which always round-trips in history.
|
||||
if id := rawString(item["id"]); id != "" {
|
||||
if cached := opts.ReasoningContentByID(id); cached != "" {
|
||||
pendingReasoning = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
if pendingReasoning != "" {
|
||||
lastTurnReasoning = pendingReasoning
|
||||
}
|
||||
continue
|
||||
case "function_call":
|
||||
@@ -276,6 +384,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
arguments = "{}"
|
||||
}
|
||||
callID := rawString(item["call_id"])
|
||||
if !json.Valid([]byte(arguments)) {
|
||||
// A previous streamed turn can leave a truncated function_call in
|
||||
// Codex history (for example after an upstream SSE parse failure or
|
||||
// an output-limit interruption). Do not forward that item to a
|
||||
// Chat Completions provider, which rejects the entire request. Its
|
||||
// matching output is skipped below as well, allowing the next user
|
||||
// turn to self-heal instead of repeatedly replaying the poison.
|
||||
if callID != "" {
|
||||
invalidFunctionCallIDs[callID] = struct{}{}
|
||||
} else {
|
||||
invalidEmptyFunctionCallOutputs++
|
||||
}
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
}
|
||||
name := rawString(item["name"])
|
||||
// namespace 子工具的历史调用带 namespace 字段,需与请求方向的摊平
|
||||
// 命名(namespaceChildrenToChatTools)保持一致。
|
||||
@@ -283,14 +407,14 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
name = flattenNamespaceToolName(ns, name)
|
||||
}
|
||||
toolCall := ChatToolCall{
|
||||
ID: rawString(item["call_id"]),
|
||||
ID: callID,
|
||||
Type: "function",
|
||||
Function: ChatFunctionCall{
|
||||
Name: name,
|
||||
Arguments: arguments,
|
||||
},
|
||||
}
|
||||
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
|
||||
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
case "tool_search_call":
|
||||
@@ -311,7 +435,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
Arguments: arguments,
|
||||
},
|
||||
}
|
||||
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
|
||||
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
case "custom_tool_call":
|
||||
@@ -327,12 +451,21 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
Arguments: string(arguments),
|
||||
},
|
||||
}
|
||||
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
|
||||
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
case "function_call_output", "custom_tool_call_output", "tool_search_output":
|
||||
outputRaw := bytesTrimSpace(item["output"])
|
||||
callID := rawString(item["call_id"])
|
||||
if callID == "" && invalidEmptyFunctionCallOutputs > 0 {
|
||||
invalidEmptyFunctionCallOutputs--
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
}
|
||||
if _, skipped := invalidFunctionCallIDs[callID]; skipped {
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
}
|
||||
delete(mediaByCallID, callID)
|
||||
|
||||
outputText, media, rewritten := extractToolOutputMedia(outputRaw)
|
||||
@@ -359,6 +492,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
content, _ := json.Marshal(rawString(item["text"]))
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
pendingReasoning = ""
|
||||
lastTurnReasoning = ""
|
||||
continue
|
||||
case "input_image":
|
||||
content, err := chatContentFromSingleResponsesPart(itemType, item)
|
||||
@@ -367,6 +501,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
}
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
pendingReasoning = ""
|
||||
lastTurnReasoning = ""
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -391,11 +526,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
messages = append(messages, ChatMessage{Role: role, Content: chatContent})
|
||||
// Reasoning only survives across an assistant text message.
|
||||
if role != "assistant" {
|
||||
msg := ChatMessage{Role: role, Content: chatContent}
|
||||
// DeepSeek thinking mode requires the reasoning_content from a prior
|
||||
// reasoning-only / plain-text assistant turn to be passed back on its
|
||||
// assistant message; dropping it yields 400 "The `reasoning_content` in
|
||||
// the thinking mode must be passed back to the API" on the next turn.
|
||||
// A following function_call in the same turn still receives it because
|
||||
// appendAssistantToolCall merges into this message and only fills
|
||||
// ReasoningContent when it is still empty.
|
||||
if role == "assistant" {
|
||||
msg.ReasoningContent = reasoningForAssistant()
|
||||
pendingReasoning = ""
|
||||
} else {
|
||||
pendingReasoning = ""
|
||||
lastTurnReasoning = ""
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, mediaByCallID, nil
|
||||
@@ -687,6 +833,26 @@ func extractResponsesReasoningText(item map[string]json.RawMessage) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// ExtractResponsesReasoningItem parses a raw Responses input item and, when it
|
||||
// is a reasoning item, returns its id and extractable plaintext (summary
|
||||
// preferred, content fallback). ok is false for non-reasoning items. It exists
|
||||
// for the gateway-side reasoning cache: items with plaintext get (re)cached so
|
||||
// later encrypted-only replicas of the same item id can be restored.
|
||||
func ExtractResponsesReasoningItem(raw json.RawMessage) (id string, text string, ok bool) {
|
||||
raw = bytesTrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", "", false
|
||||
}
|
||||
var item map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
if rawString(item["type"]) != "reasoning" {
|
||||
return "", "", false
|
||||
}
|
||||
return rawString(item["id"]), extractResponsesReasoningText(item), true
|
||||
}
|
||||
|
||||
func chatCompletionsBridgeRole(role string) string {
|
||||
trimmed := strings.TrimSpace(role)
|
||||
if trimmed == "" {
|
||||
@@ -803,6 +969,9 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) {
|
||||
topLevel := make(map[string]bool)
|
||||
for _, tool := range tools {
|
||||
if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" {
|
||||
if topLevel[tool.Name] {
|
||||
return nil, fmt.Errorf("duplicate top-level executable tool name %q; this upstream cannot disambiguate duplicate names, rename one of the tools", tool.Name)
|
||||
}
|
||||
topLevel[tool.Name] = true
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1202,7 @@ func extractCustomToolCallInput(arguments string) string {
|
||||
// toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具
|
||||
// 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名
|
||||
// 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。
|
||||
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse {
|
||||
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse {
|
||||
id := ""
|
||||
if resp != nil {
|
||||
id = resp.ID
|
||||
@@ -1053,11 +1222,12 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
|
||||
}
|
||||
|
||||
out := &ResponsesResponse{
|
||||
ID: id,
|
||||
Object: "response",
|
||||
CreatedAt: createdAt,
|
||||
Model: model,
|
||||
Status: "completed",
|
||||
ID: id,
|
||||
Object: "response",
|
||||
CreatedAt: createdAt,
|
||||
Model: model,
|
||||
Status: "completed",
|
||||
ServiceTier: chatServiceTier(resp),
|
||||
}
|
||||
if resp == nil {
|
||||
out.Output = []ResponsesOutput{emptyResponsesMessageOutput()}
|
||||
@@ -1069,7 +1239,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
|
||||
|
||||
if len(resp.Choices) > 0 {
|
||||
choice := resp.Choices[0]
|
||||
out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools)
|
||||
out.Output = chatMessageToResponsesOutput(choice.Message, customTools, functionTools, toolSearch, namespaceTools)
|
||||
if choice.FinishReason == "length" {
|
||||
out.Status = "incomplete"
|
||||
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
|
||||
@@ -1084,7 +1254,14 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
|
||||
return out
|
||||
}
|
||||
|
||||
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput {
|
||||
func chatServiceTier(resp *ChatCompletionsResponse) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
return resp.ServiceTier
|
||||
}
|
||||
|
||||
func chatMessageToResponsesOutput(message ChatMessage, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput {
|
||||
var outputs []ResponsesOutput
|
||||
reasoning := message.reasoningText()
|
||||
if reasoning != "" {
|
||||
@@ -1120,12 +1297,12 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
arguments = "{}"
|
||||
}
|
||||
if customTools[toolCall.Function.Name] {
|
||||
if customName, ok := customToolCallName(toolCall.Function.Name, customTools, functionTools, namespaceTools); ok {
|
||||
outputs = append(outputs, ResponsesOutput{
|
||||
Type: "custom_tool_call",
|
||||
ID: generateItemID(),
|
||||
CallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: customName,
|
||||
Input: extractCustomToolCallInput(arguments),
|
||||
Status: "completed",
|
||||
})
|
||||
@@ -1141,6 +1318,13 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Ordinary Responses function_call arguments must contain valid JSON.
|
||||
// Do not mark a truncated non-streaming Chat tool call as completed;
|
||||
// Codex would persist it and poison the next request in the same way as
|
||||
// the streaming variant guarded by ValidateToolCallArguments.
|
||||
if !json.Valid([]byte(arguments)) {
|
||||
continue
|
||||
}
|
||||
if ns, ok := namespaceTools[toolCall.Function.Name]; ok {
|
||||
outputs = append(outputs, ResponsesOutput{
|
||||
Type: "function_call",
|
||||
@@ -1249,6 +1433,7 @@ type ChatCompletionsToResponsesStreamState struct {
|
||||
ResponseID string
|
||||
Model string
|
||||
Created int64
|
||||
ServiceTier string // upstream Chat chunk service_tier, echoed on response events
|
||||
SequenceNumber int
|
||||
CreatedSent bool
|
||||
CompletedSent bool
|
||||
@@ -1285,6 +1470,9 @@ type ChatCompletionsToResponsesStreamState struct {
|
||||
// 路由回它注册的 custom 工具。
|
||||
CustomTools map[string]bool
|
||||
|
||||
// FunctionTools is the set of explicitly declared top-level function tools.
|
||||
FunctionTools map[string]bool
|
||||
|
||||
// ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见
|
||||
// HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按
|
||||
// 该项类型(且 execution=client)执行 tool search。
|
||||
@@ -1329,6 +1517,33 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToolCallArguments checks the accumulated function-call arguments
|
||||
// before the stream is finalized. A tool call whose argument stream was
|
||||
// truncated must not be emitted as a completed Responses item: Codex will
|
||||
// persist it and replay it on the next turn, where a Chat Completions provider
|
||||
// rejects the whole request.
|
||||
func (state *ChatCompletionsToResponsesStreamState) ValidateToolCallArguments() error {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
for idx, toolCall := range state.ToolCalls {
|
||||
if toolCall == nil {
|
||||
continue
|
||||
}
|
||||
if state.toolIsCustom[idx] || state.toolIsToolSearch[idx] {
|
||||
continue
|
||||
}
|
||||
arguments := strings.TrimSpace(toolCall.Function.Arguments)
|
||||
if arguments == "" {
|
||||
continue
|
||||
}
|
||||
if !json.Valid([]byte(arguments)) {
|
||||
return fmt.Errorf("tool call %q (%s) arguments are invalid JSON", toolCall.ID, toolCall.Function.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *ChatCompletionsToResponsesStreamState) allocOutputIndex() int {
|
||||
idx := state.nextOutputIndex
|
||||
state.nextOutputIndex++
|
||||
@@ -1350,6 +1565,9 @@ func ChatCompletionsChunkToResponsesEvents(
|
||||
if state.Model == "" && chunk.Model != "" {
|
||||
state.Model = chunk.Model
|
||||
}
|
||||
if chunk.ServiceTier != "" {
|
||||
state.ServiceTier = chunk.ServiceTier
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
state.Usage = ChatUsageToResponsesUsage(chunk.Usage)
|
||||
}
|
||||
@@ -1507,6 +1725,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr
|
||||
CreatedAt: state.Created,
|
||||
Model: state.Model,
|
||||
Status: status,
|
||||
ServiceTier: state.ServiceTier,
|
||||
Output: state.chatOutput(),
|
||||
Usage: state.Usage,
|
||||
IncompleteDetails: incompleteDetails,
|
||||
@@ -1522,12 +1741,13 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState)
|
||||
state.CreatedSent = true
|
||||
return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{
|
||||
Response: &ResponsesResponse{
|
||||
ID: state.ResponseID,
|
||||
Object: "response",
|
||||
CreatedAt: state.Created,
|
||||
Model: state.Model,
|
||||
Status: "in_progress",
|
||||
Output: []ResponsesOutput{},
|
||||
ID: state.ResponseID,
|
||||
Object: "response",
|
||||
CreatedAt: state.Created,
|
||||
Model: state.Model,
|
||||
Status: "in_progress",
|
||||
ServiceTier: state.ServiceTier,
|
||||
Output: []ResponsesOutput{},
|
||||
},
|
||||
})}
|
||||
}
|
||||
@@ -1660,11 +1880,11 @@ func announceChatToolItem(
|
||||
if state.toolAnnounced[idx] {
|
||||
return nil
|
||||
}
|
||||
if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) {
|
||||
if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || len(state.FunctionTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) {
|
||||
return nil
|
||||
}
|
||||
state.toolAnnounced[idx] = true
|
||||
isCustom := state.CustomTools[stored.Function.Name]
|
||||
customName, isCustom := customToolCallName(stored.Function.Name, state.CustomTools, state.FunctionTools, state.NamespaceTools)
|
||||
isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName
|
||||
state.toolIsCustom[idx] = isCustom
|
||||
state.toolIsToolSearch[idx] = isToolSearch
|
||||
@@ -1678,6 +1898,9 @@ func announceChatToolItem(
|
||||
// namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要
|
||||
// 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。
|
||||
itemName, itemNamespace := stored.Function.Name, ""
|
||||
if isCustom {
|
||||
itemName = customName
|
||||
}
|
||||
if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch {
|
||||
state.toolNamespace[idx] = ns
|
||||
itemName, itemNamespace = ns.Name, ns.Namespace
|
||||
@@ -1747,7 +1970,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
|
||||
OutputIndex: outputIndex,
|
||||
ItemID: itemID,
|
||||
CallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: customNameForStreamTool(state, toolCall.Function.Name),
|
||||
Input: input,
|
||||
}),
|
||||
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
|
||||
@@ -1756,7 +1979,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
|
||||
Type: "custom_tool_call",
|
||||
ID: itemID,
|
||||
CallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: customNameForStreamTool(state, toolCall.Function.Name),
|
||||
Input: input,
|
||||
Status: "completed",
|
||||
},
|
||||
@@ -1847,7 +2070,7 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp
|
||||
Type: "custom_tool_call",
|
||||
ID: generateItemID(),
|
||||
CallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: customNameForStreamTool(state, toolCall.Function.Name),
|
||||
Input: extractCustomToolCallInput(arguments),
|
||||
Status: "completed",
|
||||
})
|
||||
|
||||
+149
-4
@@ -34,6 +34,60 @@ func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *test
|
||||
assert.Equal(t, "wait", out.Tools[1].Function.Name)
|
||||
}
|
||||
|
||||
func TestResponsesChatBridge_MixedCustomAndNamespaceToolNames(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "deepseek-test",
|
||||
Input: json.RawMessage(`"run pwd"`),
|
||||
Tools: []ResponsesTool{
|
||||
{Type: "custom", Name: "exec", Description: "Runs a command"},
|
||||
{Type: "namespace", Name: "functions", Tools: []ResponsesTool{
|
||||
{Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object"}`)},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
chatReq, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chatReq.Tools, 2)
|
||||
assert.Equal(t, "exec", chatReq.Tools[0].Function.Name)
|
||||
assert.Equal(t, "functions__wait", chatReq.Tools[1].Function.Name)
|
||||
|
||||
customTools := CustomToolNames(req.Tools)
|
||||
namespaceTools := NamespaceToolNames(req.Tools)
|
||||
resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{
|
||||
{ID: "call_exact", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}},
|
||||
{ID: "call_wait", Function: ChatFunctionCall{Name: "functions__wait", Arguments: `{"cell_id":"1"}`}},
|
||||
{ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}},
|
||||
}}}}}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, req.Model, customTools, FunctionToolNames(req.Tools), false, namespaceTools)
|
||||
require.Len(t, out.Output, 3)
|
||||
assert.Equal(t, "custom_tool_call", out.Output[0].Type)
|
||||
assert.Equal(t, "exec", out.Output[0].Name)
|
||||
assert.Equal(t, "pwd", out.Output[0].Input)
|
||||
assert.Equal(t, "function_call", out.Output[1].Type)
|
||||
assert.Equal(t, "functions", out.Output[1].Namespace)
|
||||
assert.Equal(t, "wait", out.Output[1].Name)
|
||||
assert.Equal(t, "custom_tool_call", out.Output[2].Type)
|
||||
assert.Equal(t, "exec", out.Output[2].Name)
|
||||
assert.Equal(t, "not-json", out.Output[2].Input)
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponses_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) {
|
||||
resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{{
|
||||
ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`},
|
||||
}}}}}}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-test",
|
||||
map[string]bool{"exec": true}, map[string]bool{"functions__exec": true}, false,
|
||||
map[string]NamespacedToolName{"functions__wait": {Namespace: "functions", Name: "wait"}})
|
||||
|
||||
require.Len(t, out.Output, 1)
|
||||
assert.Equal(t, "function_call", out.Output[0].Type)
|
||||
assert.Equal(t, "functions__exec", out.Output[0].Name)
|
||||
assert.Equal(t, `{"path":"/tmp"}`, out.Output[0].Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
@@ -173,7 +227,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil)
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, nil, false, nil)
|
||||
require.Len(t, out.Output, 2)
|
||||
|
||||
assert.Equal(t, "custom_tool_call", out.Output[0].Type)
|
||||
@@ -264,6 +318,68 @@ func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T
|
||||
assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项")
|
||||
}
|
||||
|
||||
func TestChatCompletionsChunkToResponsesEvents_MixedCustomNamespaceAliasStream(t *testing.T) {
|
||||
state := NewChatCompletionsToResponsesStreamState("deepseek-test")
|
||||
state.CustomTools = map[string]bool{"exec": true}
|
||||
state.NamespaceTools = map[string]NamespacedToolName{
|
||||
"functions__wait": {Namespace: "functions", Name: "wait"},
|
||||
}
|
||||
|
||||
idx := 0
|
||||
chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{
|
||||
Index: &idx, ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`},
|
||||
}}}}}}
|
||||
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
|
||||
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
|
||||
|
||||
for _, evt := range events {
|
||||
if evt.Type == "response.output_item.added" && evt.Item != nil && evt.Item.Type == "custom_tool_call" {
|
||||
assert.Equal(t, "exec", evt.Item.Name)
|
||||
}
|
||||
if evt.Type == "response.custom_tool_call_input.done" {
|
||||
assert.Equal(t, "exec", evt.Name)
|
||||
assert.Equal(t, "not-json", evt.Input)
|
||||
}
|
||||
if evt.Type == "response.output_item.done" && evt.Item != nil && evt.Item.Type == "custom_tool_call" {
|
||||
assert.Equal(t, "exec", evt.Item.Name)
|
||||
assert.Equal(t, "not-json", evt.Item.Input)
|
||||
}
|
||||
}
|
||||
|
||||
final := events[len(events)-1]
|
||||
require.Equal(t, "response.completed", final.Type)
|
||||
require.Len(t, final.Response.Output, 1)
|
||||
assert.Equal(t, "custom_tool_call", final.Response.Output[0].Type)
|
||||
assert.Equal(t, "exec", final.Response.Output[0].Name)
|
||||
assert.Equal(t, "not-json", final.Response.Output[0].Input)
|
||||
}
|
||||
|
||||
func TestChatCompletionsChunkToResponsesEvents_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) {
|
||||
state := NewChatCompletionsToResponsesStreamState("deepseek-test")
|
||||
state.CustomTools = map[string]bool{"exec": true}
|
||||
state.FunctionTools = map[string]bool{"functions__exec": true}
|
||||
state.NamespaceTools = map[string]NamespacedToolName{
|
||||
"functions__wait": {Namespace: "functions", Name: "wait"},
|
||||
}
|
||||
|
||||
idx := 0
|
||||
chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{
|
||||
Index: &idx, ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`},
|
||||
}}}}}}
|
||||
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
|
||||
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
|
||||
|
||||
for _, evt := range events {
|
||||
assert.NotEqual(t, "response.custom_tool_call_input.done", evt.Type)
|
||||
}
|
||||
final := events[len(events)-1]
|
||||
require.Equal(t, "response.completed", final.Type)
|
||||
require.Len(t, final.Response.Output, 1)
|
||||
assert.Equal(t, "function_call", final.Response.Output[0].Type)
|
||||
assert.Equal(t, "functions__exec", final.Response.Output[0].Name)
|
||||
assert.Equal(t, `{"path":"/tmp"}`, final.Response.Output[0].Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "glm-5.2",
|
||||
@@ -280,6 +396,18 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t
|
||||
assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`)
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) {
|
||||
var req ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req))
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(&req)
|
||||
require.NoError(t, err)
|
||||
encoded, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded), "defer_loading")
|
||||
require.Contains(t, string(encoded), `"name":"tool_search"`)
|
||||
}
|
||||
|
||||
// codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行
|
||||
// tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配
|
||||
// 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。
|
||||
@@ -296,7 +424,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing.
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil)
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, true, nil)
|
||||
require.Len(t, out.Output, 1)
|
||||
|
||||
item := out.Output[0]
|
||||
@@ -328,7 +456,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa
|
||||
}
|
||||
|
||||
// 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil)
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nil)
|
||||
require.Len(t, out.Output, 1)
|
||||
assert.Equal(t, "function_call", out.Output[0].Type)
|
||||
}
|
||||
@@ -644,6 +772,23 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test
|
||||
assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_RejectsDuplicateTopLevelExecutableNames(t *testing.T) {
|
||||
for _, tools := range [][]ResponsesTool{
|
||||
{{Type: "custom", Name: "exec"}, {Type: "function", Name: "exec"}},
|
||||
{{Type: "function", Name: "exec"}, {Type: "function", Name: "exec"}},
|
||||
{{Type: "custom", Name: "exec"}, {Type: "custom", Name: "exec"}},
|
||||
} {
|
||||
_, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
|
||||
Model: "glm-5.2",
|
||||
Input: json.RawMessage(`"hi"`),
|
||||
Tools: tools,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exec")
|
||||
assert.Contains(t, err.Error(), "cannot disambiguate")
|
||||
}
|
||||
}
|
||||
|
||||
// tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发,
|
||||
// chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。
|
||||
func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) {
|
||||
@@ -795,7 +940,7 @@ func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testin
|
||||
"mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools)
|
||||
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nsTools)
|
||||
require.Len(t, out.Output, 2)
|
||||
|
||||
item := out.Output[0]
|
||||
|
||||
@@ -17,6 +17,62 @@ func TestResponsesInputToChatMessages_DeveloperRoleMapsToSystem(t *testing.T) {
|
||||
assert.JSONEq(t, `"follow project instructions"`, string(messages[0].Content))
|
||||
}
|
||||
|
||||
func TestResponsesInputToChatMessages_SkipsInvalidHistoricalFunctionCall(t *testing.T) {
|
||||
input := json.RawMessage(`[
|
||||
{"type":"function_call","call_id":"call_bad","name":"exec_command","arguments":"{\"cmd\": \"ssh root@HOST"},
|
||||
{"type":"function_call_output","call_id":"call_bad","output":"failed to parse function arguments"},
|
||||
{"type":"function_call","call_id":"call_ok","name":"exec_command","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_ok","output":"ok"},
|
||||
{"role":"user","content":"continue"}
|
||||
]`)
|
||||
|
||||
messages, err := responsesInputToChatMessages("", input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 3)
|
||||
require.Equal(t, "assistant", messages[0].Role)
|
||||
require.Len(t, messages[0].ToolCalls, 1)
|
||||
require.Equal(t, "call_ok", messages[0].ToolCalls[0].ID)
|
||||
require.Equal(t, "tool", messages[1].Role)
|
||||
require.Equal(t, "call_ok", messages[1].ToolCallID)
|
||||
require.Equal(t, "user", messages[2].Role)
|
||||
}
|
||||
|
||||
func TestResponsesInputToChatMessages_SkipsInvalidEmptyCallIDOutput(t *testing.T) {
|
||||
input := json.RawMessage(`[
|
||||
{"type":"function_call","call_id":"","name":"exec_command","arguments":"{\"cmd\": \"ssh root@HOST"},
|
||||
{"type":"function_call_output","call_id":"","output":"failed to parse function arguments"},
|
||||
{"role":"user","content":"continue"}
|
||||
]`)
|
||||
|
||||
messages, err := responsesInputToChatMessages("", input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "user", messages[0].Role)
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponses_SkipsInvalidFunctionArguments(t *testing.T) {
|
||||
resp := &ChatCompletionsResponse{
|
||||
Model: "deepseek-v4-flash",
|
||||
Choices: []ChatChoice{{
|
||||
Message: ChatMessage{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ChatToolCall{
|
||||
{ID: "call_bad", Type: "function", Function: ChatFunctionCall{Name: "exec_command", Arguments: `{"cmd": "ssh root@HOST`}},
|
||||
{ID: "call_ok", Type: "function", Function: ChatFunctionCall{Name: "exec_command", Arguments: `{}`}},
|
||||
},
|
||||
},
|
||||
FinishReason: "length",
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, nil, false, nil)
|
||||
require.Equal(t, "incomplete", out.Status)
|
||||
require.Len(t, out.Output, 1)
|
||||
require.Equal(t, "function_call", out.Output[0].Type)
|
||||
require.Equal(t, "call_ok", out.Output[0].CallID)
|
||||
require.Equal(t, `{}`, out.Output[0].Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesInputToChatMessages_KeepsChatCompletionRoles(t *testing.T) {
|
||||
input := json.RawMessage(`[
|
||||
{"role":"system","content":"system message"},
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Encrypted-only reasoning items (empty summary + opaque encrypted_content,
|
||||
// e.g. after codex remote compaction) carry no plaintext the bridge can map to
|
||||
// reasoning_content. The gateway-side cache keyed by reasoning item id restores
|
||||
// it; without the restore, DeepSeek thinking mode rejects the history with 400
|
||||
// "The `reasoning_content` in the thinking mode must be passed back to the API".
|
||||
func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "deepseek-reasoner",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
|
||||
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"ok"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
|
||||
ReasoningContentByID: func(itemID string) string {
|
||||
if itemID == "item_enc1" {
|
||||
return "cached thinking"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 3)
|
||||
require.Equal(t, "assistant", out.Messages[0].Role)
|
||||
require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent)
|
||||
require.Len(t, out.Messages[0].ToolCalls, 1)
|
||||
require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID)
|
||||
require.Equal(t, "tool", out.Messages[1].Role)
|
||||
require.Equal(t, "user", out.Messages[2].Role)
|
||||
}
|
||||
|
||||
// A cache miss keeps the original behavior: no reasoning_content, no error.
|
||||
func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "deepseek-reasoner",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"},
|
||||
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"ok"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
|
||||
ReasoningContentByID: func(string) string { return "" },
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 3)
|
||||
require.Empty(t, out.Messages[0].ReasoningContent)
|
||||
|
||||
// Nil options (legacy path) behaves identically.
|
||||
legacy, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, out.Messages, legacy.Messages)
|
||||
}
|
||||
|
||||
// Plaintext summary wins and the cache lookup is not consulted.
|
||||
func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "deepseek-reasoner",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
|
||||
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"ok"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
|
||||
]`),
|
||||
}
|
||||
|
||||
lookupCalled := false
|
||||
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
|
||||
ReasoningContentByID: func(string) string {
|
||||
lookupCalled = true
|
||||
return "cached thinking"
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 3)
|
||||
require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent)
|
||||
require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run")
|
||||
}
|
||||
|
||||
// DeepSeek emits reasoning only once per turn; chained tool calls
|
||||
// (reasoning → call A → output A → call B) have no reasoning item before call
|
||||
// B. The turn's reasoning must be replayed on B's assistant message, otherwise
|
||||
// DeepSeek thinking mode 400s the history ("reasoning_content ... must be
|
||||
// passed back"). Reproduced from a real codex 0.147.0 resume history.
|
||||
func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "deepseek-reasoner",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]},
|
||||
{"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_a","output":"ok"},
|
||||
{"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_b","output":"ok"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]},
|
||||
{"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]},
|
||||
{"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_c","output":"ok"}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
byCallID := map[string]ChatMessage{}
|
||||
for _, m := range out.Messages {
|
||||
for _, tc := range m.ToolCalls {
|
||||
byCallID[tc.ID] = m
|
||||
}
|
||||
}
|
||||
require.Len(t, byCallID, 3)
|
||||
require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent)
|
||||
require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent,
|
||||
"链式第二个工具调用必须回放本轮 reasoning")
|
||||
require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent,
|
||||
"user 消息后开启新轮次,不得沿用上一轮 reasoning")
|
||||
|
||||
// 每一条 assistant 消息都必须带 reasoning_content(DeepSeek 契约)。
|
||||
for i, m := range out.Messages {
|
||||
if m.Role == "assistant" {
|
||||
require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponsesReasoningItem(t *testing.T) {
|
||||
id, text, ok := ExtractResponsesReasoningItem(json.RawMessage(
|
||||
`{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "item_a", id)
|
||||
require.Equal(t, "think", text)
|
||||
|
||||
// Encrypted-only item: ok with id but empty text.
|
||||
id, text, ok = ExtractResponsesReasoningItem(json.RawMessage(
|
||||
`{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "item_b", id)
|
||||
require.Empty(t, text)
|
||||
|
||||
// Non-reasoning items are skipped.
|
||||
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(
|
||||
`{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`))
|
||||
require.False(t, ok)
|
||||
|
||||
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`))
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -214,6 +214,78 @@ func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) {
|
||||
require.Equal(t, `{"cmd":"ls"}`, argsDelta.String())
|
||||
}
|
||||
|
||||
func TestStream_InvalidToolArgumentsAreRejectedBeforeFinalize(t *testing.T) {
|
||||
idx := 0
|
||||
state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
|
||||
chunk := &ChatCompletionsChunk{
|
||||
Choices: []ChatChunkChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: ChatDelta{
|
||||
ToolCalls: []ChatToolCall{
|
||||
{
|
||||
Index: &idx,
|
||||
ID: "call_bad",
|
||||
Type: "function",
|
||||
Function: ChatFunctionCall{
|
||||
Name: "exec_command",
|
||||
Arguments: `{"cmd": "ssh root@HOST`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ChatCompletionsChunkToResponsesEvents(chunk, state)
|
||||
|
||||
err := state.ValidateToolCallArguments()
|
||||
require.ErrorContains(t, err, "invalid JSON")
|
||||
}
|
||||
|
||||
func TestStream_ValidToolCallAtOutputLimitKeepsIncompleteResponse(t *testing.T) {
|
||||
idx := 0
|
||||
state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
|
||||
chunk := &ChatCompletionsChunk{
|
||||
Choices: []ChatChunkChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: ChatDelta{
|
||||
ToolCalls: []ChatToolCall{
|
||||
{
|
||||
Index: &idx,
|
||||
ID: "call_at_limit",
|
||||
Type: "function",
|
||||
Function: ChatFunctionCall{
|
||||
Name: "exec_command",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ChatCompletionsChunkToResponsesEvents(chunk, state)
|
||||
state.FinishReason = "length"
|
||||
|
||||
require.NoError(t, state.ValidateToolCallArguments())
|
||||
events := FinalizeChatCompletionsResponsesStream(state)
|
||||
var sawArgsDone, sawIncomplete bool
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "response.function_call_arguments.done":
|
||||
sawArgsDone = true
|
||||
require.Equal(t, `{}`, event.Arguments)
|
||||
case "response.completed":
|
||||
require.NotNil(t, event.Response)
|
||||
sawIncomplete = event.Response.Status == "incomplete"
|
||||
}
|
||||
}
|
||||
require.True(t, sawArgsDone)
|
||||
require.True(t, sawIncomplete)
|
||||
}
|
||||
|
||||
// TestStream_SSEWireComplete drives the full stream through SSE encoding and
|
||||
// asserts the function_call events carry complete fields on the wire.
|
||||
func TestStream_SSEWireComplete(t *testing.T) {
|
||||
|
||||
@@ -407,6 +407,79 @@ func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testi
|
||||
assert.Equal(t, "Describe this", parts[0].Text)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_FilePartFileData(t *testing.T) {
|
||||
content := `[{"type":"text","text":"Summarize the attached document"},{"type":"file","file":{"filename":"document.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQ="}}]`
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "gpt-4o",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: json.RawMessage(content)},
|
||||
},
|
||||
}
|
||||
resp, err := ChatCompletionsToResponses(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var items []ResponsesInputItem
|
||||
require.NoError(t, json.Unmarshal(resp.Input, &items))
|
||||
require.Len(t, items, 1)
|
||||
|
||||
var parts []ResponsesContentPart
|
||||
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
|
||||
require.Len(t, parts, 2)
|
||||
assert.Equal(t, "input_text", parts[0].Type)
|
||||
assert.Equal(t, "Summarize the attached document", parts[0].Text)
|
||||
assert.Equal(t, "input_file", parts[1].Type)
|
||||
assert.Equal(t, "document.pdf", parts[1].Filename)
|
||||
assert.Equal(t, "data:application/pdf;base64,JVBERi0xLjQ=", parts[1].FileData)
|
||||
assert.Empty(t, parts[1].FileID)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_FilePartFileID(t *testing.T) {
|
||||
content := `[{"type":"file","file":{"file_id":"file-abc123"}}]`
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "gpt-4o",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: json.RawMessage(content)},
|
||||
},
|
||||
}
|
||||
resp, err := ChatCompletionsToResponses(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var items []ResponsesInputItem
|
||||
require.NoError(t, json.Unmarshal(resp.Input, &items))
|
||||
require.Len(t, items, 1)
|
||||
|
||||
var parts []ResponsesContentPart
|
||||
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, "input_file", parts[0].Type)
|
||||
assert.Equal(t, "file-abc123", parts[0].FileID)
|
||||
assert.Empty(t, parts[0].FileData)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_EmptyFilePartSkipped(t *testing.T) {
|
||||
// A file part with neither file_data nor file_id carries nothing the
|
||||
// Responses API can use; dropping it (like empty image URLs) avoids an
|
||||
// upstream 400 on an empty input_file part.
|
||||
content := `[{"type":"text","text":"Describe this"},{"type":"file","file":{"filename":"empty.pdf"}}]`
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "gpt-4o",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: json.RawMessage(content)},
|
||||
},
|
||||
}
|
||||
resp, err := ChatCompletionsToResponses(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var items []ResponsesInputItem
|
||||
require.NoError(t, json.Unmarshal(resp.Input, &items))
|
||||
require.Len(t, items, 1)
|
||||
|
||||
var parts []ResponsesContentPart
|
||||
require.NoError(t, json.Unmarshal(items[0].Content, &parts))
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, "input_text", parts[0].Type)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) {
|
||||
// Regression for #2515: the upstream Responses API rejects an input item
|
||||
// whose content field is JSON null. Any chat-completions message that
|
||||
@@ -459,7 +532,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil)
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
@@ -493,7 +566,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil)
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
|
||||
@@ -378,6 +378,15 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont
|
||||
ImageURL: p.ImageURL.URL,
|
||||
})
|
||||
}
|
||||
case "file":
|
||||
if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") {
|
||||
responseParts = append(responseParts, ResponsesContentPart{
|
||||
Type: "input_file",
|
||||
Filename: p.File.Filename,
|
||||
FileData: p.File.FileData,
|
||||
FileID: p.File.FileID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return responseParts
|
||||
|
||||
@@ -27,6 +27,13 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping,
|
||||
if !ok || len(tools) == 0 {
|
||||
return ResponsesClientToolMapping{}, false, nil
|
||||
}
|
||||
discovered, err := promoteResponsesToolSearchDiscoveries(req)
|
||||
if err != nil {
|
||||
return ResponsesClientToolMapping{}, false, err
|
||||
}
|
||||
if discovered {
|
||||
tools, _ = req["tools"].([]any)
|
||||
}
|
||||
|
||||
adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)}
|
||||
functionNames := make(map[string]bool)
|
||||
@@ -73,7 +80,7 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping,
|
||||
|
||||
tools, _ = req["tools"].([]any)
|
||||
lowered := make([]any, 0, len(tools))
|
||||
changed := flattened
|
||||
changed := discovered || flattened
|
||||
seenSearch := false
|
||||
for _, raw := range tools {
|
||||
tool, ok := raw.(map[string]any)
|
||||
@@ -112,10 +119,17 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping,
|
||||
lowered = append(lowered, raw)
|
||||
}
|
||||
}
|
||||
if stripResponsesDeferredToolFlags(lowered) {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
req["tools"] = lowered
|
||||
}
|
||||
if rewriteClientToolHistory(req["input"], &adapter) {
|
||||
historyChanged, err := rewriteClientToolHistory(req["input"], &adapter)
|
||||
if err != nil {
|
||||
return ResponsesClientToolMapping{}, false, err
|
||||
}
|
||||
if historyChanged {
|
||||
changed = true
|
||||
}
|
||||
if rewriteClientToolChoice(req, &adapter) {
|
||||
@@ -130,6 +144,70 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping,
|
||||
return adapter, changed, nil
|
||||
}
|
||||
|
||||
// stripResponsesDeferredToolFlags removes defer_loading only when the final
|
||||
// declaration list no longer contains the built-in tool_search it requires.
|
||||
func stripResponsesDeferredToolFlags(tools []any) bool {
|
||||
if hasResponsesToolSearchDeclaration(tools) {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for _, raw := range tools {
|
||||
tool, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := tool["defer_loading"]; exists {
|
||||
delete(tool, "defer_loading")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on
|
||||
// a follow-up request that omits the session-level tools declaration. An
|
||||
// explicitly present tools field, including an empty or malformed value,
|
||||
// always replaces the inherited mapping and is handled by the ordinary
|
||||
// declaration-driven adapter.
|
||||
func AdaptResponsesClientToolsWithInheritedMapping(
|
||||
req map[string]any,
|
||||
inherited ResponsesClientToolMapping,
|
||||
inheritedLoweredTools ...[]any,
|
||||
) (ResponsesClientToolMapping, bool, error) {
|
||||
if req == nil {
|
||||
return ResponsesClientToolMapping{}, false, nil
|
||||
}
|
||||
if _, toolsPresent := req["tools"]; toolsPresent {
|
||||
return AdaptResponsesClientTools(req)
|
||||
}
|
||||
if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 {
|
||||
return ResponsesClientToolMapping{}, false, nil
|
||||
}
|
||||
if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 {
|
||||
req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited)
|
||||
return AdaptResponsesClientTools(req)
|
||||
}
|
||||
|
||||
changed, err := rewriteClientToolHistory(req["input"], &inherited)
|
||||
if err != nil {
|
||||
return ResponsesClientToolMapping{}, false, err
|
||||
}
|
||||
if len(inherited.NamespaceTools) > 0 {
|
||||
before := changed
|
||||
rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools)
|
||||
// Namespace rewriting does not currently report whether it changed a
|
||||
// value. A retained namespace mapping is only used for follow-up
|
||||
// history, so conservatively rebuild the request when input exists.
|
||||
if _, inputPresent := req["input"]; inputPresent && !before {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if rewriteClientToolChoice(req, &inherited) {
|
||||
changed = true
|
||||
}
|
||||
return inherited, changed, nil
|
||||
}
|
||||
|
||||
func copyClientTool(tool map[string]any) map[string]any {
|
||||
copy := make(map[string]any, len(tool))
|
||||
for key, value := range tool {
|
||||
@@ -138,14 +216,16 @@ func copyClientTool(tool map[string]any) map[string]any {
|
||||
return copy
|
||||
}
|
||||
|
||||
func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bool {
|
||||
func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) {
|
||||
changed := false
|
||||
var visit func(any)
|
||||
visit = func(value any) {
|
||||
var visit func(any) error
|
||||
visit = func(value any) error {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
visit(item)
|
||||
if err := visit(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
typ := strings.TrimSpace(stringValue(typed["type"]))
|
||||
@@ -155,10 +235,12 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo
|
||||
typed["type"] = "function_call"
|
||||
typed["arguments"] = customToolCallArguments(stringValue(typed["input"]))
|
||||
delete(typed, "input")
|
||||
normalizeLoweredFunctionItemID(typed)
|
||||
changed = true
|
||||
}
|
||||
case "custom_tool_call_output":
|
||||
typed["type"] = "function_call_output"
|
||||
normalizeLoweredFunctionItemID(typed)
|
||||
normalizeClientToolOutput(typed)
|
||||
changed = true
|
||||
case "tool_search_call":
|
||||
@@ -167,22 +249,105 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo
|
||||
typed["name"] = toolSearchProxyName
|
||||
typed["arguments"] = rawObjectString(typed["arguments"])
|
||||
delete(typed, "execution")
|
||||
normalizeLoweredFunctionItemID(typed)
|
||||
changed = true
|
||||
}
|
||||
case "tool_search_output":
|
||||
if adapter.ToolSearch {
|
||||
callID := strings.TrimSpace(stringValue(typed["call_id"]))
|
||||
if callID == "" {
|
||||
return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output")
|
||||
}
|
||||
typed["type"] = "function_call_output"
|
||||
normalizeClientToolOutput(typed)
|
||||
normalizeLoweredFunctionItemID(typed)
|
||||
if err := normalizeToolSearchOutput(typed); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
visit(child)
|
||||
if err := visit(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(value); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// normalizeLoweredFunctionItemID reconciles Codex client-only item IDs such as
|
||||
// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the
|
||||
// function protocol, which validates these IDs with the fc prefix. A ctc_/tsc_
|
||||
// call ID maps back to the fc_ ID it was raised from; the remaining IDs have no
|
||||
// function-protocol counterpart and are dropped. call_id, which is preserved
|
||||
// separately, stays the tool call/output pairing key either way.
|
||||
func normalizeLoweredFunctionItemID(item map[string]any) {
|
||||
id := strings.TrimSpace(stringValue(item["id"]))
|
||||
if id == "" || strings.HasPrefix(id, "fc") {
|
||||
return
|
||||
}
|
||||
// A ctc_/tsc_ call ID on a lowered call item is the one we minted from the
|
||||
// upstream's own fc_ ID when the item was raised, so map it back instead of
|
||||
// dropping it. Anything else has no known function-protocol counterpart.
|
||||
if recovered := retypedResponsesToolCallItemID(id, "function_call"); recovered != id {
|
||||
item["id"] = recovered
|
||||
return
|
||||
}
|
||||
delete(item, "id")
|
||||
}
|
||||
|
||||
// responsesToolCallItemIDPrefixes lists the Responses item ID prefixes that are
|
||||
// tied to a specific tool-call item type.
|
||||
var responsesToolCallItemIDPrefixes = []string{"fc_", "ctc_", "tsc_"}
|
||||
|
||||
// responsesToolCallItemIDPrefix reports the ID prefix the Responses API
|
||||
// validates for itemType, or "" when the type constrains no prefix.
|
||||
func responsesToolCallItemIDPrefix(itemType string) string {
|
||||
switch itemType {
|
||||
case "custom_tool_call":
|
||||
return "ctc_"
|
||||
case "tool_search_call":
|
||||
return "tsc_"
|
||||
case "function_call":
|
||||
return "fc_"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// retypedResponsesToolCallItemID re-prefixes an upstream item ID so it agrees
|
||||
// with the item type we raise it back to. A function-only upstream answers a
|
||||
// lowered custom tool with an fc_ item ID; emitting that ID on the restored
|
||||
// custom_tool_call poisons the client's history, because a later replay of the
|
||||
// same item to an upstream that validates IDs fails with
|
||||
// "Invalid 'input[N].id' ... Expected an ID that begins with 'ctc'".
|
||||
// The suffix is preserved so the ID stays stable and unique per upstream item.
|
||||
// IDs that carry no known tool-call prefix are left alone rather than guessed at.
|
||||
func retypedResponsesToolCallItemID(id, itemType string) string {
|
||||
want := responsesToolCallItemIDPrefix(itemType)
|
||||
if want == "" || id == "" || strings.HasPrefix(id, want) {
|
||||
return id
|
||||
}
|
||||
for _, known := range responsesToolCallItemIDPrefixes {
|
||||
if known != want && strings.HasPrefix(id, known) {
|
||||
return want + strings.TrimPrefix(id, known)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// retypeResponsesToolCallItemID applies retypedResponsesToolCallItemID to a
|
||||
// decoded item map.
|
||||
func retypeResponsesToolCallItemID(item map[string]any, itemType string) {
|
||||
id := strings.TrimSpace(stringValue(item["id"]))
|
||||
if retyped := retypedResponsesToolCallItemID(id, itemType); retyped != id {
|
||||
item["id"] = retyped
|
||||
}
|
||||
visit(value)
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeClientToolOutput(item map[string]any) {
|
||||
@@ -205,6 +370,47 @@ func normalizeClientToolOutput(item map[string]any) {
|
||||
item["output"] = string(encoded)
|
||||
}
|
||||
|
||||
// normalizeToolSearchOutput converts both tool_search output wire shapes into
|
||||
// the string output required by function_call_output. Older clients send an
|
||||
// output field directly; newer Codex clients return discovered definitions in
|
||||
// a top-level tools field. Codex treats that field's value as the tool output,
|
||||
// so serialize the value directly rather than wrapping it in another object.
|
||||
func normalizeToolSearchOutput(item map[string]any) error {
|
||||
if output, hasOutput := item["output"]; hasOutput {
|
||||
switch typed := output.(type) {
|
||||
case string:
|
||||
item["output"] = typed
|
||||
case nil:
|
||||
item["output"] = ""
|
||||
default:
|
||||
encoded, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err)
|
||||
}
|
||||
item["output"] = string(encoded)
|
||||
}
|
||||
dropToolSearchOutputPrivateFields(item)
|
||||
return nil
|
||||
}
|
||||
tools, hasTools := item["tools"]
|
||||
if !hasTools {
|
||||
return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output")
|
||||
}
|
||||
encoded, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err)
|
||||
}
|
||||
item["output"] = string(encoded)
|
||||
dropToolSearchOutputPrivateFields(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropToolSearchOutputPrivateFields(item map[string]any) {
|
||||
delete(item, "tools")
|
||||
delete(item, "status")
|
||||
delete(item, "execution")
|
||||
}
|
||||
|
||||
func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool {
|
||||
choice, ok := req["tool_choice"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -285,12 +491,14 @@ func restoreClientToolValue(value any, adapter *ResponsesClientToolMapping) bool
|
||||
name := strings.TrimSpace(stringValue(typed["name"]))
|
||||
if adapter.CustomTools[name] {
|
||||
typed["type"] = "custom_tool_call"
|
||||
retypeResponsesToolCallItemID(typed, "custom_tool_call")
|
||||
typed["input"] = extractCustomToolCallInput(rawObjectString(typed["arguments"]))
|
||||
delete(typed, "arguments")
|
||||
delete(typed, "namespace")
|
||||
changed = true
|
||||
} else if adapter.ToolSearch && name == toolSearchProxyName {
|
||||
typed["type"] = "tool_search_call"
|
||||
retypeResponsesToolCallItemID(typed, "tool_search_call")
|
||||
typed["execution"] = "client"
|
||||
typed["arguments"] = json.RawMessage(toolSearchCallArgumentsJSON(rawObjectString(typed["arguments"])))
|
||||
delete(typed, "name")
|
||||
@@ -317,12 +525,15 @@ type ResponsesClientToolStreamRestorer struct {
|
||||
}
|
||||
|
||||
type responsesClientToolStreamCall struct {
|
||||
kind string
|
||||
name string
|
||||
callID string
|
||||
itemID string
|
||||
outputIdx int
|
||||
arguments strings.Builder
|
||||
kind string
|
||||
name string
|
||||
// callID and itemID stay as the upstream sent them so later upstream
|
||||
// events keep matching this call; clientItemID is what we emit.
|
||||
callID string
|
||||
itemID string
|
||||
clientItemID string
|
||||
outputIdx int
|
||||
arguments strings.Builder
|
||||
}
|
||||
|
||||
func NewResponsesClientToolStreamRestorer(mapping ResponsesClientToolMapping) *ResponsesClientToolStreamRestorer {
|
||||
@@ -361,6 +572,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
|
||||
event.Item.Arguments = "{}"
|
||||
event.Item.Namespace = ""
|
||||
}
|
||||
if call.clientItemID != "" {
|
||||
event.Item.ID = call.clientItemID
|
||||
}
|
||||
}
|
||||
emit(r.restoreNamespaceEvent(event))
|
||||
case "response.function_call_arguments.delta":
|
||||
@@ -378,9 +592,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
|
||||
if call.kind == "custom" {
|
||||
input := extractCustomToolCallInput(call.arguments.String())
|
||||
if input != "" {
|
||||
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.itemID, Delta: input})
|
||||
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.clientItemID, Delta: input})
|
||||
}
|
||||
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.itemID, CallID: call.callID, Name: call.name, Input: input})
|
||||
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.clientItemID, CallID: call.callID, Name: call.name, Input: input})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -401,6 +615,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent)
|
||||
}
|
||||
event.Item.Namespace = ""
|
||||
}
|
||||
if call.clientItemID != "" {
|
||||
event.Item.ID = call.clientItemID
|
||||
}
|
||||
delete(r.calls, call.itemID)
|
||||
delete(r.calls, call.callID)
|
||||
delete(r.byOutput, call.outputIdx)
|
||||
@@ -430,7 +647,7 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if wire.Type == "response.completed" || wire.Type == "response.incomplete" || wire.Type == "response.failed" {
|
||||
if isResponsesClientToolTerminalEvent(wire.Type) {
|
||||
restored, changed, err := RestoreResponsesClientToolPayload(payload, r.adapter)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -465,6 +682,15 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func isResponsesClientToolTerminalEvent(typ string) bool {
|
||||
switch strings.TrimSpace(typ) {
|
||||
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResponsesClientToolStreamRestorer) clientToolEventPayload(payload []byte) bool {
|
||||
var raw struct {
|
||||
ItemID string `json:"item_id"`
|
||||
@@ -528,6 +754,15 @@ func (r *ResponsesClientToolStreamRestorer) resequenceRaw(payload []byte, sequen
|
||||
return [][]byte{encoded}, true, nil
|
||||
}
|
||||
|
||||
// responsesClientToolItemType maps a restorer call kind to the item type the
|
||||
// client sees.
|
||||
func responsesClientToolItemType(kind string) string {
|
||||
if kind == "custom" {
|
||||
return "custom_tool_call"
|
||||
}
|
||||
return "tool_search_call"
|
||||
}
|
||||
|
||||
func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEvent) *responsesClientToolStreamCall {
|
||||
if event.Item == nil || event.Item.Type != "function_call" {
|
||||
return nil
|
||||
@@ -548,7 +783,14 @@ func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEven
|
||||
}
|
||||
call := r.calls[key]
|
||||
if call == nil {
|
||||
call = &responsesClientToolStreamCall{kind: kind, name: name, callID: event.Item.CallID, itemID: event.Item.ID, outputIdx: event.OutputIndex}
|
||||
call = &responsesClientToolStreamCall{
|
||||
kind: kind,
|
||||
name: name,
|
||||
callID: event.Item.CallID,
|
||||
itemID: event.Item.ID,
|
||||
clientItemID: retypedResponsesToolCallItemID(event.Item.ID, responsesClientToolItemType(kind)),
|
||||
outputIdx: event.OutputIndex,
|
||||
}
|
||||
r.calls[key] = call
|
||||
if call.callID != "" {
|
||||
r.calls[call.callID] = call
|
||||
@@ -602,11 +844,13 @@ func restoreResponsesOutputClientTools(outputs []ResponsesOutput, adapter *Respo
|
||||
}
|
||||
if adapter.CustomTools[output.Name] {
|
||||
output.Type = "custom_tool_call"
|
||||
output.ID = retypedResponsesToolCallItemID(output.ID, output.Type)
|
||||
output.Input = extractCustomToolCallInput(output.Arguments)
|
||||
output.Arguments = ""
|
||||
output.Namespace = ""
|
||||
} else if adapter.ToolSearch && output.Name == toolSearchProxyName {
|
||||
output.Type = "tool_search_call"
|
||||
output.ID = retypedResponsesToolCallItemID(output.ID, output.Type)
|
||||
output.Name = ""
|
||||
output.Namespace = ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRetypedResponsesToolCallItemID(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id string
|
||||
itemType string
|
||||
want string
|
||||
}{
|
||||
{"function id raised to custom", "fc_abc", "custom_tool_call", "ctc_abc"},
|
||||
{"function id raised to tool search", "fc_abc", "tool_search_call", "tsc_abc"},
|
||||
{"already correct is untouched", "ctc_abc", "custom_tool_call", "ctc_abc"},
|
||||
{"custom id lowered to function", "ctc_abc", "function_call", "fc_abc"},
|
||||
{"unknown prefix is left alone", "item_abc", "custom_tool_call", "item_abc"},
|
||||
{"unprefixed id is left alone", "abc", "custom_tool_call", "abc"},
|
||||
{"empty id stays empty", "", "custom_tool_call", ""},
|
||||
{"unconstrained item type is left alone", "fc_abc", "message", "fc_abc"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, retypedResponsesToolCallItemID(tc.id, tc.itemType))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A function-only upstream answers a lowered custom tool with an fc_ item ID.
|
||||
// Emitting that ID on the restored custom_tool_call breaks the client the next
|
||||
// time the item is replayed to an upstream that validates item IDs:
|
||||
// "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with 'ctc'".
|
||||
func TestRestoreResponsesClientToolPayload_RetypesToolCallItemIDs(t *testing.T) {
|
||||
mapping := ResponsesClientToolMapping{
|
||||
CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
|
||||
NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}},
|
||||
}
|
||||
payload := []byte(`{"id":"resp","output":[` +
|
||||
`{"type":"function_call","id":"fc_abc123","call_id":"call_1","name":"exec","arguments":"{\"input\":\"dir\"}"},` +
|
||||
`{"type":"function_call","id":"fc_def456","call_id":"call_2","name":"tool_search","arguments":"{\"query\":\"git\"}"},` +
|
||||
`{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"team__send","arguments":"{}"}]}`)
|
||||
|
||||
restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"id":"resp","output":[`+
|
||||
`{"type":"custom_tool_call","id":"ctc_abc123","call_id":"call_1","name":"exec","input":"dir"},`+
|
||||
`{"type":"tool_search_call","id":"tsc_def456","call_id":"call_2","execution":"client","arguments":{"query":"git"}},`+
|
||||
`{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"send","namespace":"team","arguments":"{}"}]}`,
|
||||
string(restored))
|
||||
}
|
||||
|
||||
func TestRestoreResponsesOutputClientTools_RetypesToolCallItemIDs(t *testing.T) {
|
||||
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}, ToolSearch: true}
|
||||
outputs := []ResponsesOutput{
|
||||
{Type: "function_call", ID: "fc_abc123", CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`},
|
||||
{Type: "function_call", ID: "fc_def456", CallID: "call_2", Name: toolSearchProxyName, Arguments: `{"query":"git"}`},
|
||||
}
|
||||
|
||||
restoreResponsesOutputClientTools(outputs, &mapping)
|
||||
|
||||
require.Equal(t, "custom_tool_call", outputs[0].Type)
|
||||
require.Equal(t, "ctc_abc123", outputs[0].ID)
|
||||
require.Equal(t, "call_1", outputs[0].CallID)
|
||||
require.Equal(t, "tool_search_call", outputs[1].Type)
|
||||
require.Equal(t, "tsc_def456", outputs[1].ID)
|
||||
require.Equal(t, "call_2", outputs[1].CallID)
|
||||
}
|
||||
|
||||
func TestResponsesClientToolStreamRestorer_RetypesCustomToolCallItemID(t *testing.T) {
|
||||
const upstreamID = "fc_09f77ac43cf7db36016a8920e7934487"
|
||||
const clientID = "ctc_09f77ac43cf7db36016a8920e7934487"
|
||||
|
||||
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
|
||||
|
||||
added := restorer.Restore(ResponsesStreamEvent{
|
||||
Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Status: "in_progress"},
|
||||
})
|
||||
require.Len(t, added, 1)
|
||||
require.Equal(t, "custom_tool_call", added[0].Item.Type)
|
||||
require.Equal(t, clientID, added[0].Item.ID)
|
||||
|
||||
// Later upstream events still address the item by its upstream ID.
|
||||
require.Empty(t, restorer.Restore(ResponsesStreamEvent{
|
||||
Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: upstreamID, Delta: `{"input":"di`,
|
||||
}))
|
||||
done := restorer.Restore(ResponsesStreamEvent{
|
||||
Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: upstreamID,
|
||||
CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`,
|
||||
})
|
||||
require.Len(t, done, 2)
|
||||
require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type)
|
||||
require.Equal(t, clientID, done[0].ItemID)
|
||||
require.Equal(t, "response.custom_tool_call_input.done", done[1].Type)
|
||||
require.Equal(t, clientID, done[1].ItemID)
|
||||
require.Equal(t, "call_1", done[1].CallID)
|
||||
|
||||
closed := restorer.Restore(ResponsesStreamEvent{
|
||||
Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"},
|
||||
})
|
||||
require.Len(t, closed, 1)
|
||||
require.Equal(t, "custom_tool_call", closed[0].Item.Type)
|
||||
require.Equal(t, clientID, closed[0].Item.ID)
|
||||
require.Equal(t, "dir", closed[0].Item.Input)
|
||||
}
|
||||
|
||||
func TestResponsesClientToolStreamRestorer_RetypesToolSearchCallItemID(t *testing.T) {
|
||||
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true})
|
||||
|
||||
added := restorer.Restore(ResponsesStreamEvent{
|
||||
Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", ID: "fc_search1", CallID: "call_1", Name: toolSearchProxyName, Status: "in_progress"},
|
||||
})
|
||||
require.Len(t, added, 1)
|
||||
require.Equal(t, "tool_search_call", added[0].Item.Type)
|
||||
require.Equal(t, "tsc_search1", added[0].Item.ID)
|
||||
}
|
||||
|
||||
// The WS bridge replays restored items back to the upstream, so the ID we hand
|
||||
// the client has to map back to the upstream's own fc_ ID on the way down.
|
||||
func TestAdaptResponsesClientTools_RecoversRetypedToolCallItemID(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{
|
||||
map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "tool_search"},
|
||||
},
|
||||
"input": []any{
|
||||
map[string]any{"type": "custom_tool_call", "id": "ctc_upstream1", "call_id": "call_1", "name": "exec", "input": "dir"},
|
||||
map[string]any{"type": "tool_search_call", "id": "tsc_upstream2", "call_id": "call_2", "arguments": map[string]any{"query": "git"}},
|
||||
map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "call_1", "output": "ok"},
|
||||
},
|
||||
}
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
input := requireResponsesClientToolValue[[]any](t, req["input"])
|
||||
require.Len(t, input, 3)
|
||||
customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
|
||||
require.Equal(t, "function_call", customCall["type"])
|
||||
require.Equal(t, "fc_upstream1", customCall["id"])
|
||||
searchCall := requireResponsesClientToolValue[map[string]any](t, input[1])
|
||||
require.Equal(t, "function_call", searchCall["type"])
|
||||
require.Equal(t, "fc_upstream2", searchCall["id"])
|
||||
// Output items have no function-protocol ID counterpart and stay dropped.
|
||||
customOutput := requireResponsesClientToolValue[map[string]any](t, input[2])
|
||||
require.Equal(t, "function_call_output", customOutput["type"])
|
||||
require.NotContains(t, customOutput, "id")
|
||||
}
|
||||
@@ -17,10 +17,10 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(
|
||||
},
|
||||
"tool_choice": map[string]any{"type": "custom", "name": "exec"},
|
||||
"input": []any{
|
||||
map[string]any{"type": "custom_tool_call", "call_id": "c1", "name": "exec", "input": "dir"},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "c1", "output": "ok"},
|
||||
map[string]any{"type": "tool_search_call", "call_id": "s1", "arguments": map[string]any{"query": "git"}},
|
||||
map[string]any{"type": "tool_search_output", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}},
|
||||
map[string]any{"type": "custom_tool_call", "id": "ctc_client", "call_id": "c1", "name": "exec", "input": "dir"},
|
||||
map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "c1", "output": "ok"},
|
||||
map[string]any{"type": "tool_search_call", "id": "tsc_client", "call_id": "s1", "arguments": map[string]any{"query": "git"}},
|
||||
map[string]any{"type": "tool_search_output", "id": "tso_client", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}},
|
||||
map[string]any{"type": "function_call", "call_id": "n1", "namespace": "team", "name": "send", "arguments": "{}"},
|
||||
},
|
||||
}
|
||||
@@ -48,20 +48,370 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(
|
||||
input := requireResponsesClientToolValue[[]any](t, req["input"])
|
||||
customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
|
||||
require.Equal(t, "function_call", customCall["type"])
|
||||
require.Equal(t, "fc_client", customCall["id"])
|
||||
require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"]))
|
||||
customOutput := requireResponsesClientToolValue[map[string]any](t, input[1])
|
||||
require.Equal(t, "function_call_output", customOutput["type"])
|
||||
require.NotContains(t, customOutput, "id")
|
||||
searchCall := requireResponsesClientToolValue[map[string]any](t, input[2])
|
||||
require.Equal(t, "function_call", searchCall["type"])
|
||||
require.Equal(t, "fc_client", searchCall["id"])
|
||||
require.Equal(t, toolSearchProxyName, searchCall["name"])
|
||||
require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"]))
|
||||
searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3])
|
||||
require.Equal(t, "function_call_output", searchOutput["type"])
|
||||
require.NotContains(t, searchOutput, "id")
|
||||
require.JSONEq(t, `{"groups":["git"]}`, requireResponsesClientToolValue[string](t, searchOutput["output"]))
|
||||
namespaceCall := requireResponsesClientToolValue[map[string]any](t, input[4])
|
||||
require.Equal(t, "team__send", namespaceCall["name"])
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{
|
||||
map[string]any{"type": "tool_search"},
|
||||
map[string]any{"type": "function", "name": "shell", "defer_loading": true},
|
||||
map[string]any{"type": "function", "name": "apply_patch"},
|
||||
},
|
||||
}
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
|
||||
require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"])
|
||||
require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading")
|
||||
}
|
||||
|
||||
func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) {
|
||||
tools := []any{
|
||||
map[string]any{"type": "tool_search"},
|
||||
map[string]any{"type": "function", "name": "shell", "defer_loading": true},
|
||||
}
|
||||
|
||||
require.False(t, stripResponsesDeferredToolFlags(tools))
|
||||
require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"])
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) {
|
||||
requestJSON := `{
|
||||
"tools":[{"type":"tool_search"}],
|
||||
"input":[
|
||||
{"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"},
|
||||
{"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[
|
||||
{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
|
||||
{"type":"namespace","name":"multi_agent_v1","tools":[
|
||||
{"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
|
||||
{"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
|
||||
]}
|
||||
]}
|
||||
]
|
||||
}`
|
||||
|
||||
type adaptedRequest struct {
|
||||
req map[string]any
|
||||
mapping ResponsesClientToolMapping
|
||||
}
|
||||
adapt := func() adaptedRequest {
|
||||
var req map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(requestJSON), &req))
|
||||
mapping, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
return adaptedRequest{req: req, mapping: mapping}
|
||||
}
|
||||
|
||||
first := adapt()
|
||||
second := adapt()
|
||||
firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"])
|
||||
secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"])
|
||||
|
||||
tools := requireResponsesClientToolValue[[]any](t, first.req["tools"])
|
||||
require.Len(t, tools, 4)
|
||||
require.Equal(t, []string{
|
||||
"tool_search",
|
||||
"codex_app__load_workspace_dependencies",
|
||||
"multi_agent_v1__spawn_agent",
|
||||
"multi_agent_v1__wait_agent",
|
||||
}, responsesClientToolNames(t, tools))
|
||||
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
|
||||
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"])
|
||||
|
||||
call := requireResponsesClientToolValue[map[string]any](t, firstInput[0])
|
||||
require.Equal(t, "function_call", call["type"])
|
||||
require.Equal(t, toolSearchProxyName, call["name"])
|
||||
require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
|
||||
require.NotContains(t, call, "execution")
|
||||
|
||||
output := requireResponsesClientToolValue[map[string]any](t, firstInput[1])
|
||||
require.Equal(t, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_search",
|
||||
"output": output["output"],
|
||||
}, output)
|
||||
outputText := requireResponsesClientToolValue[string](t, output["output"])
|
||||
require.JSONEq(t, `[
|
||||
{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
|
||||
{"type":"namespace","name":"multi_agent_v1","tools":[
|
||||
{"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
|
||||
{"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
|
||||
]}
|
||||
]`, outputText)
|
||||
secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1])
|
||||
require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic")
|
||||
|
||||
restored, changed, err := RestoreResponsesClientToolPayload(
|
||||
[]byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`),
|
||||
first.mapping,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored))
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) {
|
||||
direct := map[string]any{
|
||||
"type": "function", "name": "inspect_result", "description": "Inspect a result",
|
||||
"parameters": map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}
|
||||
custom := map[string]any{
|
||||
"type": "custom", "name": "run_script", "description": "Run a script",
|
||||
"format": map[string]any{"type": "grammar"},
|
||||
}
|
||||
namespace := map[string]any{
|
||||
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
|
||||
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
|
||||
}},
|
||||
}
|
||||
req := map[string]any{
|
||||
"tools": []any{
|
||||
map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
|
||||
map[string]any{"type": "tool_search"},
|
||||
},
|
||||
"input": []any{
|
||||
map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}},
|
||||
map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}},
|
||||
},
|
||||
}
|
||||
|
||||
mapping, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.True(t, mapping.CustomTools["run_script"])
|
||||
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
|
||||
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
|
||||
require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools))
|
||||
customTool := requireResponsesClientToolValue[map[string]any](t, tools[3])
|
||||
require.Equal(t, "function", customTool["type"])
|
||||
require.NotContains(t, customTool, "format")
|
||||
for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) {
|
||||
item := requireResponsesClientToolValue[map[string]any](t, raw)
|
||||
require.Equal(t, "function_call_output", item["type"])
|
||||
require.NotContains(t, item, "tools")
|
||||
require.NotContains(t, item, "status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
staticTools []any
|
||||
discovered []any
|
||||
}{
|
||||
{
|
||||
name: "direct schema collision",
|
||||
staticTools: []any{map[string]any{
|
||||
"type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"},
|
||||
}},
|
||||
discovered: []any{map[string]any{
|
||||
"type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "namespace schema collision",
|
||||
staticTools: []any{map[string]any{
|
||||
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
|
||||
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
|
||||
}},
|
||||
}},
|
||||
discovered: []any{map[string]any{
|
||||
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
|
||||
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "flattened namespace collision",
|
||||
staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}},
|
||||
discovered: []any{map[string]any{
|
||||
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": append(tt.staticTools, map[string]any{"type": "tool_search"}),
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output", "status": "completed", "tools": tt.discovered,
|
||||
}},
|
||||
}
|
||||
_, _, err := AdaptResponsesClientTools(req)
|
||||
require.ErrorContains(t, err, "conflicts")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{map[string]any{"type": "tool_search"}},
|
||||
"input": []any{
|
||||
map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}},
|
||||
map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}},
|
||||
},
|
||||
}
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "the static tool_search declaration is still lowered")
|
||||
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
|
||||
require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools))
|
||||
}
|
||||
|
||||
func responsesClientToolNames(t *testing.T, tools []any) []string {
|
||||
t.Helper()
|
||||
names := make([]string, 0, len(tools))
|
||||
for _, raw := range tools {
|
||||
tool := requireResponsesClientToolValue[map[string]any](t, raw)
|
||||
names = append(names, requireResponsesClientToolValue[string](t, tool["name"]))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) {
|
||||
unencodableOutput := make(chan struct{})
|
||||
tests := []struct {
|
||||
name string
|
||||
item map[string]any
|
||||
wantOutput any
|
||||
wantOutputExists bool
|
||||
wantPrivateKeys []string
|
||||
wantExactOutput bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "absent tools and output is rejected",
|
||||
item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"},
|
||||
wantOutputExists: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "preexisting string output wins",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_legacy", "output": "legacy",
|
||||
"tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client",
|
||||
},
|
||||
wantOutput: "legacy",
|
||||
wantOutputExists: true,
|
||||
wantExactOutput: true,
|
||||
},
|
||||
{
|
||||
name: "preexisting object output remains legacy representation",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}},
|
||||
"tools": []any{map[string]any{"type": "function", "name": "ignored"}},
|
||||
},
|
||||
wantOutput: `{"groups":["github"]}`,
|
||||
wantOutputExists: true,
|
||||
wantExactOutput: true,
|
||||
},
|
||||
{
|
||||
name: "unencodable preexisting output is rejected",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput,
|
||||
"tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client",
|
||||
},
|
||||
wantOutput: unencodableOutput,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty tools array is a valid empty output",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_empty_tools",
|
||||
"tools": []any{}, "status": "completed", "execution": "client",
|
||||
},
|
||||
wantOutput: `[]`,
|
||||
wantOutputExists: true,
|
||||
wantExactOutput: true,
|
||||
},
|
||||
{
|
||||
name: "non-array tools value is serialized directly",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_malformed",
|
||||
"tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client",
|
||||
},
|
||||
wantOutput: `{"unexpected":true}`,
|
||||
wantOutputExists: true,
|
||||
wantExactOutput: true,
|
||||
},
|
||||
{
|
||||
name: "unencodable tools is rejected",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing call id is rejected",
|
||||
item: map[string]any{
|
||||
"type": "tool_search_output", "tools": []any{}, "status": "completed",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{map[string]any{"type": "tool_search"}},
|
||||
"input": []any{tt.item},
|
||||
}
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
input := requireResponsesClientToolValue[[]any](t, req["input"])
|
||||
output := requireResponsesClientToolValue[map[string]any](t, input[0])
|
||||
require.Equal(t, "function_call_output", output["type"])
|
||||
actualOutput, outputExists := output["output"]
|
||||
require.Equal(t, tt.wantOutputExists, outputExists)
|
||||
if tt.wantOutputExists {
|
||||
require.Equal(t, tt.wantOutput, actualOutput)
|
||||
}
|
||||
if tt.wantExactOutput {
|
||||
require.Equal(t, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": output["call_id"],
|
||||
"output": tt.wantOutput,
|
||||
}, output)
|
||||
}
|
||||
if len(tt.wantPrivateKeys) > 0 {
|
||||
for _, key := range tt.wantPrivateKeys {
|
||||
require.Contains(t, output, key)
|
||||
}
|
||||
} else {
|
||||
require.NotContains(t, output, "tools")
|
||||
require.NotContains(t, output, "status")
|
||||
require.NotContains(t, output, "execution")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requireResponsesClientToolValue[T any](t *testing.T, value any) T {
|
||||
t.Helper()
|
||||
typed, ok := value.(T)
|
||||
@@ -81,6 +431,122 @@ func TestAdaptResponsesClientTools_RejectsAmbiguousNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWithoutTools(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"input": []any{
|
||||
map[string]any{
|
||||
"type": "custom_tool_call", "name": "exec",
|
||||
"call_id": "call_1", "input": "pwd",
|
||||
},
|
||||
map[string]any{
|
||||
"type": "custom_tool_call_output", "call_id": "call_1",
|
||||
"id": "ctco_client_output_1",
|
||||
"output": []any{map[string]any{"type": "input_text", "text": "ok"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
inherited := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}
|
||||
|
||||
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, inherited, mapping)
|
||||
items := requireResponsesClientToolValue[[]any](t, req["input"])
|
||||
call := requireResponsesClientToolValue[map[string]any](t, items[0])
|
||||
require.Equal(t, "function_call", call["type"])
|
||||
require.JSONEq(t, `{"input":"pwd"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
|
||||
require.NotContains(t, call, "input")
|
||||
output := requireResponsesClientToolValue[map[string]any](t, items[1])
|
||||
require.Equal(t, "function_call_output", output["type"])
|
||||
require.NotContains(t, output, "id")
|
||||
require.JSONEq(t, `[{"text":"ok","type":"input_text"}]`, requireResponsesClientToolValue[string](t, output["output"]))
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client",
|
||||
"tools": []any{map[string]any{
|
||||
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
|
||||
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
inherited := ResponsesClientToolMapping{
|
||||
ToolSearch: true,
|
||||
NamespaceTools: map[string]ResponsesNamespaceName{
|
||||
"codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"},
|
||||
},
|
||||
}
|
||||
lowered := []any{
|
||||
map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
|
||||
map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)},
|
||||
map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}},
|
||||
}
|
||||
|
||||
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.True(t, mapping.ToolSearch)
|
||||
require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"])
|
||||
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
|
||||
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
|
||||
require.Equal(t, []string{
|
||||
"static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent",
|
||||
}, responsesClientToolNames(t, tools))
|
||||
output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
|
||||
require.Equal(t, "function_call_output", output["type"])
|
||||
require.IsType(t, "", output["output"])
|
||||
require.NotContains(t, output, "tools")
|
||||
require.NotContains(t, output, "status")
|
||||
require.NotContains(t, output, "execution")
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{},
|
||||
"input": []any{map[string]any{
|
||||
"type": "custom_tool_call", "name": "exec", "input": "pwd",
|
||||
}},
|
||||
}
|
||||
|
||||
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
|
||||
req,
|
||||
ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Empty(t, mapping)
|
||||
items := requireResponsesClientToolValue[[]any](t, req["input"])
|
||||
call := requireResponsesClientToolValue[map[string]any](t, items[0])
|
||||
require.Equal(t, "custom_tool_call", call["type"])
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) {
|
||||
for _, reset := range []any{nil, []any{}} {
|
||||
req := map[string]any{
|
||||
"tools": reset,
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output", "call_id": "call_reset", "status": "completed",
|
||||
"tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}},
|
||||
}},
|
||||
}
|
||||
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
|
||||
req,
|
||||
ResponsesClientToolMapping{ToolSearch: true},
|
||||
[]any{map[string]any{"type": "function", "name": "tool_search"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Empty(t, mapping)
|
||||
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
|
||||
require.Equal(t, "tool_search_output", item["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) {
|
||||
mapping := ResponsesClientToolMapping{
|
||||
CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
|
||||
@@ -174,3 +640,30 @@ func TestResponsesClientToolStreamRestorer_RawEventsPreserveUnknownFieldsAndOutp
|
||||
require.Len(t, done, 2)
|
||||
require.Equal(t, "pwd", done[1].Input)
|
||||
}
|
||||
|
||||
func TestResponsesClientToolStreamRestorer_RestoresAllTerminalEvents(t *testing.T) {
|
||||
for _, eventType := range []string{
|
||||
"response.completed",
|
||||
"response.done",
|
||||
"response.incomplete",
|
||||
"response.failed",
|
||||
"response.cancelled",
|
||||
"response.canceled",
|
||||
} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
|
||||
payload := []byte(`{"type":"` + eventType + `","sequence_number":7,"response":{"id":"resp_tools","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}]}}`)
|
||||
|
||||
restored, changed, err := restorer.RestoreEvent(payload)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, restored, 1)
|
||||
require.Equal(t, eventType, gjson.GetBytes(restored[0], "type").String())
|
||||
require.Equal(t, int64(7), gjson.GetBytes(restored[0], "sequence_number").Int())
|
||||
require.Equal(t, "custom_tool_call", gjson.GetBytes(restored[0], "response.output.0.type").String())
|
||||
require.Equal(t, "pwd", gjson.GetBytes(restored[0], "response.output.0.input").String())
|
||||
require.False(t, gjson.GetBytes(restored[0], "response.output.0.arguments").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestChatCompletionsResponseToResponses_CarriesCreatedAt(t *testing.T) {
|
||||
Created: 1700000000,
|
||||
Model: "deepseek-v4-flash",
|
||||
Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
|
||||
}, "deepseek-v4-flash", nil, false, nil)
|
||||
}, "deepseek-v4-flash", nil, nil, false, nil)
|
||||
require.EqualValues(t, 1700000000, out.CreatedAt, "上游给了 created 就照搬,不要另起时间")
|
||||
})
|
||||
|
||||
@@ -63,12 +63,12 @@ func TestChatCompletionsResponseToResponses_CarriesCreatedAt(t *testing.T) {
|
||||
ID: "chatcmpl_2",
|
||||
Model: "deepseek-v4-flash",
|
||||
Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}},
|
||||
}, "deepseek-v4-flash", nil, false, nil)
|
||||
}, "deepseek-v4-flash", nil, nil, false, nil)
|
||||
require.Greater(t, out.CreatedAt, int64(0))
|
||||
})
|
||||
|
||||
t.Run("nil_upstream_response_still_stamps", func(t *testing.T) {
|
||||
out := ChatCompletionsResponseToResponses(nil, "deepseek-v4-flash", nil, false, nil)
|
||||
out := ChatCompletionsResponseToResponses(nil, "deepseek-v4-flash", nil, nil, false, nil)
|
||||
require.Greater(t, out.CreatedAt, int64(0), "空上游响应也必须产出可解析的对象")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
|
||||
}
|
||||
|
||||
out := &ChatCompletionsResponse{
|
||||
ID: id,
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
ID: id,
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
ServiceTier: resp.ServiceTier,
|
||||
}
|
||||
|
||||
var contentText string
|
||||
@@ -118,6 +119,7 @@ type ResponsesEventToChatState struct {
|
||||
ID string
|
||||
Model string
|
||||
Created int64
|
||||
ServiceTier string // upstream tier observed on response events; echoed on chunks
|
||||
SentRole bool
|
||||
SawToolCall bool
|
||||
SawText bool
|
||||
@@ -187,12 +189,13 @@ func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatComplet
|
||||
|
||||
if state.IncludeUsage && state.Usage != nil {
|
||||
chunks = append(chunks, ChatCompletionsChunk{
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
Choices: []ChatChunkChoice{},
|
||||
Usage: state.Usage,
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ServiceTier: state.ServiceTier,
|
||||
Choices: []ChatChunkChoice{},
|
||||
Usage: state.Usage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,6 +221,9 @@ func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToCh
|
||||
if state.Model == "" && evt.Response.Model != "" {
|
||||
state.Model = evt.Response.Model
|
||||
}
|
||||
if evt.Response.ServiceTier != "" {
|
||||
state.ServiceTier = evt.Response.ServiceTier
|
||||
}
|
||||
}
|
||||
// Emit the role chunk.
|
||||
if state.SentRole {
|
||||
@@ -301,6 +307,9 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
|
||||
if evt.Response.Usage != nil {
|
||||
state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage)
|
||||
}
|
||||
if evt.Response.ServiceTier != "" {
|
||||
state.ServiceTier = evt.Response.ServiceTier
|
||||
}
|
||||
|
||||
switch evt.Response.Status {
|
||||
case "incomplete":
|
||||
@@ -326,12 +335,13 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
|
||||
|
||||
if state.IncludeUsage && state.Usage != nil {
|
||||
chunks = append(chunks, ChatCompletionsChunk{
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
Choices: []ChatChunkChoice{},
|
||||
Usage: state.Usage,
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ServiceTier: state.ServiceTier,
|
||||
Choices: []ChatChunkChoice{},
|
||||
Usage: state.Usage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -401,10 +411,11 @@ func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatToke
|
||||
|
||||
func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk {
|
||||
return ChatCompletionsChunk{
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ServiceTier: state.ServiceTier,
|
||||
Choices: []ChatChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: delta,
|
||||
@@ -416,10 +427,11 @@ func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatC
|
||||
func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk {
|
||||
empty := ""
|
||||
return ChatCompletionsChunk{
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ID: state.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: state.Created,
|
||||
Model: state.Model,
|
||||
ServiceTier: state.ServiceTier,
|
||||
Choices: []ChatChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: ChatDelta{Content: &empty},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestResponsesEventToChatChunks_ArgumentsDeltaOmitsEmptyName pins that
|
||||
// streamed arguments-only deltas never re-emit the tool name as an empty
|
||||
// string. OpenAI-compatible clients accumulate the name from the first
|
||||
// tool-call delta; a trailing "name":"" would overwrite the accumulated name
|
||||
// and break tool dispatch ("unknown tool").
|
||||
func TestResponsesEventToChatChunks_ArgumentsDeltaOmitsEmptyName(t *testing.T) {
|
||||
state := NewResponsesEventToChatState()
|
||||
|
||||
added := ResponsesEventToChatChunks(&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{
|
||||
Type: "function_call",
|
||||
CallID: "call_a",
|
||||
Name: "exec",
|
||||
},
|
||||
}, state)
|
||||
require.Len(t, added, 1)
|
||||
first, err := json.Marshal(added[0])
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(first), `"name":"exec"`)
|
||||
|
||||
for _, fragment := range []string{`{"cmd":"ls"}`, `{"cmd":"ls","flags":"-la"}`} {
|
||||
deltas := ResponsesEventToChatChunks(&ResponsesStreamEvent{
|
||||
Type: "response.function_call_arguments.delta",
|
||||
OutputIndex: 0,
|
||||
Delta: fragment,
|
||||
}, state)
|
||||
require.Len(t, deltas, 1)
|
||||
raw, err := json.Marshal(deltas[0])
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), `"name"`, "arguments delta must not carry a name field")
|
||||
require.Contains(t, string(raw), `"arguments"`)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user