From b6c4105a942385f6f862a9fa8b1c2dd07975f3b7 Mon Sep 17 00:00:00 2001 From: chensipeng Date: Mon, 25 May 2026 20:36:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E8=A1=A5=E5=85=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=83=BD=E5=8A=9B=E7=BB=A7=E6=89=BF=E4=B8=8E=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 base model 默认能力与平台覆盖项 - 在模型响应中补齐 text_generate 的上下文与推理能力字段 - 为相关逻辑补充测试和迁移脚本 --- apps/api/internal/httpapi/model_response.go | 166 ++++++++++++++ .../internal/httpapi/model_response_test.go | 206 ++++++++++++++++++ apps/api/internal/store/platform_models.go | 51 ++++- .../internal/store/platform_models_test.go | 79 +++++++ apps/api/internal/store/postgres.go | 21 +- ...odel_context_and_thinking_capabilities.sql | 165 ++++++++++++++ 6 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 apps/api/internal/httpapi/model_response_test.go create mode 100644 apps/api/internal/store/platform_models_test.go create mode 100644 apps/api/migrations/0041_model_context_and_thinking_capabilities.sql diff --git a/apps/api/internal/httpapi/model_response.go b/apps/api/internal/httpapi/model_response.go index 4e53d7e..ded775b 100644 --- a/apps/api/internal/httpapi/model_response.go +++ b/apps/api/internal/httpapi/model_response.go @@ -7,6 +7,8 @@ import ( ) func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel { + model.Capabilities = store.EffectivePlatformModelCapabilities(model.BaseCapabilities, model.Capabilities) + model.Capabilities = enrichResponseCapabilities(model) model = s.withEffectiveResponseBillingConfig(ctx, model) return store.FilterPlatformModelBillingConfig(model) } @@ -46,3 +48,167 @@ func mergeResponseBillingConfig(base map[string]any, override map[string]any) ma } return out } + +func enrichResponseCapabilities(model store.PlatformModel) map[string]any { + if len(model.Capabilities) == 0 { + return model.Capabilities + } + textGenerate, ok := enrichedTextGenerateCapabilities(model) + if !ok { + return model.Capabilities + } + + out := make(map[string]any, len(model.Capabilities)+1) + for key, value := range model.Capabilities { + out[key] = value + } + out["text_generate"] = textGenerate + return out +} + +func enrichedTextGenerateCapabilities(model store.PlatformModel) (map[string]any, bool) { + textGenerate := nestedCapabilities(model.Capabilities, "text_generate") + if textGenerate == nil && !declaresModelType(model, "text_generate") { + return nil, false + } + + patch := map[string]any{} + if _, ok := textGenerate["max_context_tokens"]; !ok { + if value, ok := textGenerateContextTokens(model, textGenerate); ok { + patch["max_context_tokens"] = value + } + } + if _, ok := textGenerate["supportThinking"]; !ok { + if value, ok := textGenerateSupportThinking(model, textGenerate); ok { + patch["supportThinking"] = value + } + } + if _, ok := textGenerate["thinkingEffortLevels"]; !ok { + if value, ok := textGenerateThinkingEffortLevels(model, textGenerate); ok { + patch["thinkingEffortLevels"] = value + } + } + if len(patch) == 0 { + return nil, false + } + + out := make(map[string]any, len(textGenerate)+len(patch)) + for key, value := range textGenerate { + out[key] = value + } + for key, value := range patch { + out[key] = value + } + return out, true +} + +func textGenerateContextTokens(model store.PlatformModel, textGenerate map[string]any) (any, bool) { + if value, ok := capabilityValue(textGenerate, "maxContextTokens"); ok { + return value, true + } + return capabilityValue(model.Capabilities, "max_context_tokens", "maxContextTokens", "contextWindow") +} + +func textGenerateSupportThinking(model store.PlatformModel, textGenerate map[string]any) (any, bool) { + if value, ok := capabilityValue(model.Capabilities, "supportThinking"); ok { + return value, true + } + if _, ok := capabilityValue(textGenerate, "thinkingEffortLevels"); ok { + return true, true + } + if _, ok := capabilityValue(model.Capabilities, "thinkingEffortLevels"); ok { + return true, true + } + return capabilityValue(model.Capabilities, "reasoning") +} + +func textGenerateThinkingEffortLevels(model store.PlatformModel, textGenerate map[string]any) (any, bool) { + if value, ok := capabilityValue(model.Capabilities, "thinkingEffortLevels"); ok { + return value, true + } + if hasTextGenerateThinkingCapability(model, textGenerate) { + return []string{}, true + } + return nil, false +} + +func hasTextGenerateThinkingCapability(model store.PlatformModel, textGenerate map[string]any) bool { + if boolCapabilityValue(textGenerate, "supportThinking", "supportThinkingModeSwitch") { + return true + } + if boolCapabilityValue(model.Capabilities, "supportThinking", "supportThinkingModeSwitch", "reasoning") { + return true + } + _, ok := capabilityValue(textGenerate, "max_thinking_tokens", "maxThinkingTokens") + return ok +} + +func declaresModelType(model store.PlatformModel, modelType string) bool { + if containsString(model.ModelType, modelType) { + return true + } + if originalTypes, ok := stringListValue(model.Capabilities["originalTypes"]); ok { + return containsString(originalTypes, modelType) + } + return false +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} + +func nestedCapabilities(capabilities map[string]any, key string) map[string]any { + if nested, ok := capabilities[key].(map[string]any); ok { + return nested + } + return nil +} + +func capabilityValue(capabilities map[string]any, keys ...string) (any, bool) { + if len(capabilities) == 0 { + return nil, false + } + for _, key := range keys { + if value, ok := capabilities[key]; ok { + return value, true + } + } + return nil, false +} + +func boolCapabilityValue(capabilities map[string]any, keys ...string) bool { + if len(capabilities) == 0 { + return false + } + for _, key := range keys { + flag, ok := capabilities[key].(bool) + if ok && flag { + return true + } + } + return false +} + +func stringListValue(value any) ([]string, bool) { + switch items := value.(type) { + case []string: + return items, len(items) > 0 + case []any: + out := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok || text == "" { + continue + } + out = append(out, text) + } + return out, len(out) > 0 + default: + return nil, false + } +} diff --git a/apps/api/internal/httpapi/model_response_test.go b/apps/api/internal/httpapi/model_response_test.go new file mode 100644 index 0000000..e3fd661 --- /dev/null +++ b/apps/api/internal/httpapi/model_response_test.go @@ -0,0 +1,206 @@ +package httpapi + +import ( + "context" + "slices" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestPlatformModelResponseExposesTextGenerateContextAndThinkingFromNestedCapabilities(t *testing.T) { + model := store.PlatformModel{ + ModelName: "gemini-3-pro-preview", + ModelType: store.StringList{"text_generate"}, + Capabilities: map[string]any{ + "text_generate": map[string]any{ + "max_context_tokens": 1000000, + "supportThinking": true, + "thinkingEffortLevels": []any{"minimal", "low", "medium", "high"}, + }, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + if textGenerate["max_context_tokens"] != 1000000 { + t.Fatalf("expected text_generate.max_context_tokens 1000000, got %#v", textGenerate["max_context_tokens"]) + } + if textGenerate["supportThinking"] != true { + t.Fatalf("expected text_generate.supportThinking true, got %#v", textGenerate["supportThinking"]) + } + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium", "high"}) + if _, ok := response.Capabilities["contextWindow"]; ok { + t.Fatalf("expected contextWindow root alias to be omitted, got %#v", response.Capabilities["contextWindow"]) + } + if _, ok := response.Capabilities["thinkingEffortLevels"]; ok { + t.Fatalf("expected thinkingEffortLevels root alias to be omitted, got %#v", response.Capabilities["thinkingEffortLevels"]) + } +} + +func TestPlatformModelResponseCopiesRootTextCapabilityFieldsToTextGenerate(t *testing.T) { + model := store.PlatformModel{ + ModelName: "legacy-root-capability-model", + ModelType: store.StringList{"text_generate"}, + Capabilities: map[string]any{ + "maxContextTokens": 128000, + "supportThinking": true, + "thinkingEffortLevels": []any{"low", "medium"}, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + if textGenerate["max_context_tokens"] != 128000 { + t.Fatalf("expected text_generate.max_context_tokens 128000, got %#v", textGenerate["max_context_tokens"]) + } + if textGenerate["supportThinking"] != true { + t.Fatalf("expected text_generate.supportThinking true, got %#v", textGenerate["supportThinking"]) + } + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"low", "medium"}) +} + +func TestPlatformModelResponseExposesEmptyThinkingEffortLevelsWhenOnlyThinkingSwitchIsConfigured(t *testing.T) { + model := store.PlatformModel{ + ModelName: "thinking-switch-model", + ModelType: store.StringList{"text_generate"}, + Capabilities: map[string]any{ + "text_generate": map[string]any{ + "max_context_tokens": 262144, + "max_thinking_tokens": 32768, + "supportThinking": true, + "supportThinkingModeSwitch": true, + "supportStructuredOutput": true, + }, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + if textGenerate["supportThinking"] != true { + t.Fatalf("expected text_generate.supportThinking true, got %#v", textGenerate["supportThinking"]) + } + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{}) +} + +func TestPlatformModelResponseInheritsMissingTextGenerateThinkingLevelsFromBaseModel(t *testing.T) { + model := store.PlatformModel{ + ModelName: "glm-4-7-251222", + ModelType: store.StringList{"text_generate"}, + BaseCapabilities: map[string]any{ + "originalTypes": []any{"text_generate"}, + "text_generate": map[string]any{ + "max_context_tokens": 204800, + "supportThinking": true, + "thinkingEffortLevels": []any{"none", "minimal", "low", "medium", "high"}, + }, + }, + Capabilities: map[string]any{ + "originalTypes": []any{"text_generate"}, + "text_generate": map[string]any{ + "max_context_tokens": 204800, + "max_thinking_tokens": 131072, + "supportThinking": true, + "supportThinkingModeSwitch": true, + }, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"none", "minimal", "low", "medium", "high"}) + if textGenerate["max_thinking_tokens"] != 131072 { + t.Fatalf("expected platform text_generate.max_thinking_tokens to be preserved, got %#v", textGenerate["max_thinking_tokens"]) + } +} + +func TestPlatformModelResponseUsesOriginalTypesWhenModelTypeIsMissing(t *testing.T) { + model := store.PlatformModel{ + ModelName: "catalog-snapshot-model", + Capabilities: map[string]any{ + "originalTypes": []any{"text_generate"}, + "text_generate": map[string]any{ + "max_context_tokens": 262144, + "supportThinking": true, + "thinkingEffortLevels": []any{"high", "max"}, + }, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + if textGenerate["max_context_tokens"] != 262144 { + t.Fatalf("expected text_generate.max_context_tokens 262144, got %#v", textGenerate["max_context_tokens"]) + } + if textGenerate["supportThinking"] != true { + t.Fatalf("expected text_generate.supportThinking true, got %#v", textGenerate["supportThinking"]) + } + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"high", "max"}) +} + +func TestPlatformModelResponsePreservesTextGenerateFieldsOverFallbacks(t *testing.T) { + model := store.PlatformModel{ + ModelName: "reasoning-model-with-tools", + ModelType: store.StringList{"text_generate", "tools_call"}, + Capabilities: map[string]any{ + "maxContextTokens": 999999, + "supportThinking": false, + "text_generate": map[string]any{ + "max_context_tokens": 1000000, + "supportThinking": true, + "thinkingEffortLevels": []any{"minimal", "low", "medium"}, + }, + "tools_call": map[string]any{ + "thinkingEffortLevels": []any{"medium", "high"}, + }, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + textGenerate := textGenerateCapabilities(t, response) + + if textGenerate["max_context_tokens"] != 1000000 { + t.Fatalf("expected text_generate.max_context_tokens to stay 1000000, got %#v", textGenerate["max_context_tokens"]) + } + if textGenerate["supportThinking"] != true { + t.Fatalf("expected text_generate.supportThinking to stay true, got %#v", textGenerate["supportThinking"]) + } + assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium"}) +} + +func textGenerateCapabilities(t *testing.T, model store.PlatformModel) map[string]any { + t.Helper() + capabilities, ok := model.Capabilities["text_generate"].(map[string]any) + if !ok { + t.Fatalf("expected capabilities.text_generate object, got %#v", model.Capabilities["text_generate"]) + } + return capabilities +} + +func assertStringListValue(t *testing.T, got any, want []string) { + t.Helper() + var items []string + switch value := got.(type) { + case []string: + items = value + case []any: + items = make([]string, 0, len(value)) + for _, item := range value { + text, ok := item.(string) + if !ok { + t.Fatalf("expected string list %v, got non-string item %#v in %#v", want, item, got) + } + items = append(items, text) + } + default: + t.Fatalf("expected string list %v, got %#v", want, got) + } + if !slices.Equal(items, want) { + t.Fatalf("expected string list %v, got %v", want, items) + } +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index b324bc5..83c284a 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -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{} diff --git a/apps/api/internal/store/platform_models_test.go b/apps/api/internal/store/platform_models_test.go new file mode 100644 index 0000000..4391179 --- /dev/null +++ b/apps/api/internal/store/platform_models_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 8850040..1adf780 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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) diff --git a/apps/api/migrations/0041_model_context_and_thinking_capabilities.sql b/apps/api/migrations/0041_model_context_and_thinking_capabilities.sql new file mode 100644 index 0000000..1a93e54 --- /dev/null +++ b/apps/api/migrations/0041_model_context_and_thinking_capabilities.sql @@ -0,0 +1,165 @@ +-- 为内置文本模型补充上下文窗口配置,并为官方明确支持 +-- reasoning_effort / thinking_level 的模型补充可用推理深度。 + +CREATE OR REPLACE FUNCTION pg_temp._tmp_merge_model_capability_defaults(base jsonb, patch jsonb) +RETURNS jsonb AS $$ +DECLARE + out jsonb := COALESCE(base, '{}'::jsonb); + patch_key text; + patch_value jsonb; +BEGIN + IF patch IS NULL OR patch = '{}'::jsonb THEN + RETURN out; + END IF; + + FOR patch_key, patch_value IN SELECT key, value FROM jsonb_each(patch) LOOP + IF jsonb_typeof(patch_value) = 'object' AND jsonb_typeof(out -> patch_key) = 'object' THEN + -- 仅补默认值,保留已有/人工配置的精确模型能力。 + out := jsonb_set(out, ARRAY[patch_key], patch_value || (out -> patch_key), true); + ELSIF NOT out ? patch_key THEN + out := jsonb_set(out, ARRAY[patch_key], patch_value, true); + END IF; + END LOOP; + + RETURN out; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION pg_temp._tmp_model_capability_patches() +RETURNS TABLE(provider_key text, provider_model_name text, capability_patch jsonb) AS $$ +WITH patch_defs(patch_key, capability_patch) AS ( + VALUES + ('doubao_seed_2', '{ + "text_generate": {"max_context_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportThinking": true, "supportThinkingModeSwitch": true}, + "image_analysis": {"max_context_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportThinking": true, "supportThinkingModeSwitch": true}, + "tools_call": {"max_context_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportTool": true, "supportThinking": true, "supportThinkingModeSwitch": true} + }'::jsonb), + ('doubao_seed_18', '{ + "text_generate": {"max_context_tokens": 262144, "max_input_tokens": 229376, "max_output_tokens": 32768, "max_thinking_tokens": 32768, "supportThinking": true, "supportThinkingModeSwitch": true, "supportStructuredOutput": true}, + "image_analysis": {"max_context_tokens": 262144, "max_input_tokens": 229376, "max_output_tokens": 32768, "max_thinking_tokens": 32768, "supportThinking": true, "supportThinkingModeSwitch": true}, + "tools_call": {"max_context_tokens": 262144, "max_input_tokens": 229376, "max_output_tokens": 32768, "max_thinking_tokens": 32768, "supportTool": true, "supportThinking": true, "supportThinkingModeSwitch": true, "supportStructuredOutput": true} + }'::jsonb), + ('glm_47', '{ + "text_generate": {"max_context_tokens": 204800, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportThinking": true, "supportThinkingModeSwitch": true}, + "image_analysis": {"max_context_tokens": 204800, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportThinking": true, "supportThinkingModeSwitch": true}, + "tools_call": {"max_context_tokens": 204800, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportTool": true, "supportThinking": true, "supportThinkingModeSwitch": true} + }'::jsonb), + ('glm_47_text_tools', '{ + "text_generate": {"max_context_tokens": 204800, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportThinking": true, "supportThinkingModeSwitch": true}, + "tools_call": {"max_context_tokens": 204800, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_thinking_tokens": 131072, "supportTool": true, "supportThinking": true, "supportThinkingModeSwitch": true} + }'::jsonb), + ('gemini_3_text', '{ + "text_generate": {"thinkingEffortLevels": ["minimal", "low", "medium", "high"]}, + "tools_call": {"thinkingEffortLevels": ["minimal", "low", "medium", "high"]}, + "omni": {"thinkingEffortLevels": ["minimal", "low", "medium", "high"]} + }'::jsonb), + ('deepseek_v4', '{ + "text_generate": {"thinkingEffortLevels": ["high", "max"]}, + "tools_call": {"thinkingEffortLevels": ["high", "max"]} + }'::jsonb) +), +model_patches(provider_key, provider_model_name, patch_key) AS ( + VALUES + ('easyai', 'Doubao Seed 2.0 Pro', 'doubao_seed_2'), + ('easyai', 'Doubao Seed 2.0 Lite', 'doubao_seed_2'), + ('easyai', 'Doubao Seed 2.0 Mini', 'doubao_seed_2'), + ('easyai', 'Doubao Seed 2.0 Code Preview', 'doubao_seed_2'), + ('volces-openai', 'doubao-seed-2-0-pro-260215', 'doubao_seed_2'), + ('volces-openai', 'doubao-seed-2-0-lite-260215', 'doubao_seed_2'), + ('volces-openai', 'doubao-seed-2-0-mini-260215', 'doubao_seed_2'), + ('volces-openai', 'doubao-seed-2-0-code-preview-260215', 'doubao_seed_2'), + + ('easyai', 'Doubao Seed 1.8', 'doubao_seed_18'), + ('volces-openai', 'doubao-seed-1-8-251228', 'doubao_seed_18'), + + ('easyai', 'GLM-4.7', 'glm_47'), + ('volces-openai', 'glm-4-7-251222', 'glm_47'), + ('zhipu-openai', 'glm-4.7', 'glm_47_text_tools'), + + ('easyai', 'Gemini-3 Pro 预览版', 'gemini_3_text'), + ('easyai', 'Gemini-3 Flash 预览版', 'gemini_3_text'), + ('easyai', 'Gemini-3.1 Flash Lite 预览版', 'gemini_3_text'), + ('easyai', 'Gemini-3.1 Pro 预览版', 'gemini_3_text'), + ('gemini', 'gemini-3-pro-preview', 'gemini_3_text'), + ('gemini', 'gemini-3-flash-preview', 'gemini_3_text'), + ('gemini', 'gemini-3.1-flash-lite-preview', 'gemini_3_text'), + ('gemini', 'gemini-3.1-pro-preview', 'gemini_3_text'), + ('gemini-openai', 'gemini-3-pro-preview', 'gemini_3_text'), + ('gemini-openai', 'gemini-3-flash-preview', 'gemini_3_text'), + ('gemini-openai', 'gemini-3.1-flash-lite-preview', 'gemini_3_text'), + ('gemini-openai', 'gemini-3.1-pro-preview', 'gemini_3_text'), + + ('easyai', 'DeepSeek-V4-Pro', 'deepseek_v4'), + ('easyai', 'DeepSeek-V4-Flash', 'deepseek_v4'), + ('aliyun-bailian-openai', 'deepseek-v4-pro', 'deepseek_v4'), + ('aliyun-bailian-openai', 'deepseek-v4-flash', 'deepseek_v4'), + ('deepseek-openai', 'deepseek-v4-pro', 'deepseek_v4'), + ('deepseek-openai', 'deepseek-v4-flash', 'deepseek_v4') +) +SELECT model_patches.provider_key, model_patches.provider_model_name, patch_defs.capability_patch +FROM model_patches +JOIN patch_defs ON patch_defs.patch_key = model_patches.patch_key; +$$ LANGUAGE sql; + +UPDATE base_model_catalog b +SET capabilities = pg_temp._tmp_merge_model_capability_defaults(b.capabilities, patches.capability_patch), + default_snapshot = CASE + WHEN COALESCE(b.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN b.default_snapshot + WHEN jsonb_typeof(b.default_snapshot->'metadata'->'rawModel') = 'object' THEN jsonb_set( + jsonb_set( + b.default_snapshot, + '{capabilities}', + pg_temp._tmp_merge_model_capability_defaults( + COALESCE(b.default_snapshot->'capabilities', '{}'::jsonb), + patches.capability_patch + ), + true + ), + '{metadata,rawModel,capabilities}', + pg_temp._tmp_merge_model_capability_defaults( + COALESCE(b.default_snapshot->'metadata'->'rawModel'->'capabilities', '{}'::jsonb), + patches.capability_patch + ), + true + ) + ELSE jsonb_set( + b.default_snapshot, + '{capabilities}', + pg_temp._tmp_merge_model_capability_defaults( + COALESCE(b.default_snapshot->'capabilities', '{}'::jsonb), + patches.capability_patch + ), + true + ) + END, + metadata = CASE + WHEN jsonb_typeof(b.metadata->'rawModel') = 'object' THEN jsonb_set( + b.metadata, + '{rawModel,capabilities}', + pg_temp._tmp_merge_model_capability_defaults( + COALESCE(b.metadata->'rawModel'->'capabilities', '{}'::jsonb), + patches.capability_patch + ), + true + ) + ELSE b.metadata + END, + updated_at = now() +FROM pg_temp._tmp_model_capability_patches() patches +WHERE b.provider_key = patches.provider_key + AND b.provider_model_name = patches.provider_model_name + AND b.catalog_type = 'system'; + +UPDATE platform_models m +SET capabilities = pg_temp._tmp_merge_model_capability_defaults(m.capabilities, patches.capability_patch), + updated_at = now() +FROM integration_platforms p, pg_temp._tmp_model_capability_patches() patches +WHERE m.platform_id = p.id + AND p.provider = patches.provider_key + AND ( + m.model_name = patches.provider_model_name + OR m.provider_model_name = patches.provider_model_name + ); + +DROP FUNCTION pg_temp._tmp_model_capability_patches(); +DROP FUNCTION pg_temp._tmp_merge_model_capability_defaults(jsonb, jsonb);