fix(api): 补全模型能力继承与响应推导

- 合并 base model 默认能力与平台覆盖项
- 在模型响应中补齐 text_generate 的上下文与推理能力字段
- 为相关逻辑补充测试和迁移脚本
This commit is contained in:
2026-05-25 20:36:32 +08:00
parent 3d23918542
commit b6c4105a94
6 changed files with 686 additions and 2 deletions
+50 -1
View File
@@ -30,6 +30,13 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod
return s.createPlatformModel(ctx, s.pool, input)
}
// EffectivePlatformModelCapabilities merges base defaults with platform overrides.
// Nested capability objects are merged recursively so platform fields override
// only the same nested keys while preserving other base capability defaults.
func EffectivePlatformModelCapabilities(base map[string]any, override map[string]any) map[string]any {
return mergeCapabilityObjects(base, override)
}
func (s *Store) ReplacePlatformModels(ctx context.Context, platformID string, inputs []CreatePlatformModelInput) ([]PlatformModel, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -107,7 +114,7 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
}
capabilities := input.Capabilities
if len(capabilities) == 0 {
capabilities = mergeObjects(base.Capabilities, input.CapabilityOverride)
capabilities = EffectivePlatformModelCapabilities(base.Capabilities, input.CapabilityOverride)
}
billingConfig := input.BillingConfig
if len(billingConfig) == 0 {
@@ -355,6 +362,48 @@ func mergeObjects(base map[string]any, override map[string]any) map[string]any {
return out
}
func mergeCapabilityObjects(base map[string]any, override map[string]any) map[string]any {
out := cloneObject(base)
for key, value := range override {
baseChild, baseOK := out[key].(map[string]any)
overrideChild, overrideOK := value.(map[string]any)
if baseOK && overrideOK {
out[key] = mergeCapabilityObjects(baseChild, overrideChild)
continue
}
out[key] = cloneObjectValue(value)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneObject(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for key, value := range values {
out[key] = cloneObjectValue(value)
}
return out
}
func cloneObjectValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneObject(typed)
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneObjectValue(item))
}
return out
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
func emptyObjectIfNil(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
@@ -0,0 +1,79 @@
package store
import (
"slices"
"testing"
)
func TestEffectivePlatformModelCapabilitiesDeepMergesNestedObjects(t *testing.T) {
baseLevels := []any{"none", "minimal", "low"}
baseTextGenerate := map[string]any{
"max_context_tokens": 204800,
"supportThinking": true,
"thinkingEffortLevels": baseLevels,
}
overrideTextGenerate := map[string]any{
"max_thinking_tokens": 131072,
"supportThinking": false,
"supportThinkingModeSwitch": true,
}
capabilities := EffectivePlatformModelCapabilities(
map[string]any{
"originalTypes": []any{"text_generate"},
"text_generate": baseTextGenerate,
},
map[string]any{
"text_generate": overrideTextGenerate,
},
)
textGenerate, ok := capabilities["text_generate"].(map[string]any)
if !ok {
t.Fatalf("expected text_generate capabilities object, got %#v", capabilities["text_generate"])
}
if textGenerate["max_context_tokens"] != 204800 {
t.Fatalf("expected base max_context_tokens to be preserved, got %#v", textGenerate["max_context_tokens"])
}
if textGenerate["max_thinking_tokens"] != 131072 {
t.Fatalf("expected override max_thinking_tokens to be applied, got %#v", textGenerate["max_thinking_tokens"])
}
if textGenerate["supportThinking"] != false {
t.Fatalf("expected override supportThinking false to win, got %#v", textGenerate["supportThinking"])
}
if textGenerate["supportThinkingModeSwitch"] != true {
t.Fatalf("expected override supportThinkingModeSwitch true, got %#v", textGenerate["supportThinkingModeSwitch"])
}
assertAnyStringList(t, textGenerate["thinkingEffortLevels"], []string{"none", "minimal", "low"})
baseTextGenerate["max_context_tokens"] = 1
overrideTextGenerate["max_thinking_tokens"] = 2
baseLevels[0] = "changed"
if textGenerate["max_context_tokens"] != 204800 {
t.Fatalf("expected merged capabilities to be detached from base mutations, got %#v", textGenerate["max_context_tokens"])
}
if textGenerate["max_thinking_tokens"] != 131072 {
t.Fatalf("expected merged capabilities to be detached from override mutations, got %#v", textGenerate["max_thinking_tokens"])
}
assertAnyStringList(t, textGenerate["thinkingEffortLevels"], []string{"none", "minimal", "low"})
}
func assertAnyStringList(t *testing.T, got any, want []string) {
t.Helper()
items, ok := got.([]any)
if !ok {
t.Fatalf("expected string list %v, got %#v", want, got)
}
normalized := make([]string, 0, len(items))
for _, item := range items {
text, ok := item.(string)
if !ok {
t.Fatalf("expected string list %v, got non-string item %#v in %#v", want, item, got)
}
normalized = append(normalized, text)
}
if !slices.Equal(normalized, want) {
t.Fatalf("expected string list %v, got %v", want, normalized)
}
}
+20 -1
View File
@@ -150,6 +150,7 @@ type PlatformModel struct {
DisplayName string `json:"displayName"`
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
BaseCapabilities map[string]any `json:"-"`
PricingMode string `json:"pricingMode"`
DiscountFactor float64 `json:"discountFactor,omitempty"`
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
@@ -806,13 +807,28 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo
rows, err := s.pool.Query(ctx, `
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name,
m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override,
COALESCE(to_char(m.cooldown_until AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), ''),
m.enabled, m.created_at, m.updated_at
FROM platform_models m
JOIN integration_platforms p ON p.id = m.platform_id
LEFT JOIN LATERAL (
SELECT catalog.capabilities
FROM base_model_catalog catalog
WHERE (m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id)
OR (
catalog.provider_key = p.provider
AND (
catalog.provider_model_name = COALESCE(NULLIF(m.provider_model_name, ''), m.model_name)
OR catalog.canonical_model_key = p.provider || ':' || COALESCE(NULLIF(m.provider_model_name, ''), m.model_name)
)
)
ORDER BY CASE WHEN m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id THEN 0 ELSE 1 END,
catalog.updated_at DESC
LIMIT 1
) b ON true
`+where+`
ORDER BY m.model_type ASC, m.model_name ASC`, args...)
if err != nil {
@@ -825,6 +841,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
var model PlatformModel
var capabilityOverride []byte
var capabilities []byte
var baseCapabilities []byte
var billingConfigOverride []byte
var billingConfig []byte
var permissionConfig []byte
@@ -845,6 +862,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
&model.DisplayName,
&capabilityOverride,
&capabilities,
&baseCapabilities,
&model.PricingMode,
&model.DiscountFactor,
&model.PricingRuleSetID,
@@ -864,6 +882,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
}
model.CapabilityOverride = decodeObject(capabilityOverride)
model.Capabilities = decodeObject(capabilities)
model.BaseCapabilities = decodeObject(baseCapabilities)
model.ModelType = decodeStringArray(modelTypeBytes)
model.BillingConfigOverride = decodeObject(billingConfigOverride)
model.BillingConfig = decodeObject(billingConfig)