fix(media): 支持大尺寸图像响应并补齐迁移契约
- 为图像协议放宽 JSON 响应上限并显式处理读取与超限错误 - 合并基础模型与平台能力,避免运行时丢失分辨率和比例约束 - 固化稳定 Gemini 图像模型的能力、计价和 preview 兼容别名
This commit is contained in:
@@ -13,6 +13,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMaxJSONResponseBytes int64 = 16 << 20
|
||||||
|
mediaMaxJSONResponseBytes int64 = 128 << 20
|
||||||
|
)
|
||||||
|
|
||||||
func credential(candidate map[string]any, keys ...string) string {
|
func credential(candidate map[string]any, keys ...string) string {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value, ok := candidate[key].(string); ok && strings.TrimSpace(value) != "" {
|
if value, ok := candidate[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
@@ -50,7 +55,12 @@ func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
|
|||||||
|
|
||||||
func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[string]any, *WireResponse, error) {
|
func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[string]any, *WireResponse, error) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
maxBytes := maxJSONResponseBytesForProtocol(protocol)
|
||||||
|
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||||
|
tooLarge := int64(len(raw)) > maxBytes
|
||||||
|
if tooLarge {
|
||||||
|
raw = raw[:maxBytes]
|
||||||
|
}
|
||||||
wire := &WireResponse{
|
wire := &WireResponse{
|
||||||
Protocol: strings.TrimSpace(protocol),
|
Protocol: strings.TrimSpace(protocol),
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
@@ -60,6 +70,26 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st
|
|||||||
if len(raw) > 0 {
|
if len(raw) > 0 {
|
||||||
_ = json.Unmarshal(raw, &wire.Body)
|
_ = json.Unmarshal(raw, &wire.Body)
|
||||||
}
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, wire, &ClientError{
|
||||||
|
Code: "response_read_error",
|
||||||
|
Message: readErr.Error(),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestID: requestIDFromHTTPResponse(resp),
|
||||||
|
Retryable: true,
|
||||||
|
Wire: wire,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tooLarge {
|
||||||
|
return nil, wire, &ClientError{
|
||||||
|
Code: "response_too_large",
|
||||||
|
Message: fmt.Sprintf("upstream JSON response exceeds %d bytes", maxBytes),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestID: requestIDFromHTTPResponse(resp),
|
||||||
|
Retryable: false,
|
||||||
|
Wire: wire,
|
||||||
|
}
|
||||||
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return nil, wire, &ClientError{
|
return nil, wire, &ClientError{
|
||||||
Code: statusCodeName(resp.StatusCode),
|
Code: statusCodeName(resp.StatusCode),
|
||||||
@@ -82,6 +112,15 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st
|
|||||||
return out, wire, nil
|
return out, wire, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxJSONResponseBytesForProtocol(protocol string) int64 {
|
||||||
|
switch strings.TrimSpace(protocol) {
|
||||||
|
case ProtocolGeminiGenerateContent, ProtocolOpenAIImages:
|
||||||
|
return mediaMaxJSONResponseBytes
|
||||||
|
default:
|
||||||
|
return defaultMaxJSONResponseBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func compatibleResponseHeaders(source http.Header) map[string][]string {
|
func compatibleResponseHeaders(source http.Header) map[string][]string {
|
||||||
if len(source) == 0 {
|
if len(source) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -46,6 +46,46 @@ func TestDecodeHTTPResponseForProtocolCapturesOfficialErrorWire(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeHTTPResponseForProtocolAllowsLargeImagePayload(t *testing.T) {
|
||||||
|
payload := `{"data":"` + strings.Repeat("a", int(defaultMaxJSONResponseBytes)) + `"}`
|
||||||
|
response := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": {"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(payload)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, wire, err := decodeHTTPResponseForProtocol(response, ProtocolGeminiGenerateContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("large Gemini image response failed: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := result["data"].(string); len(got) != int(defaultMaxJSONResponseBytes) {
|
||||||
|
t.Fatalf("large Gemini image payload length = %d", len(got))
|
||||||
|
}
|
||||||
|
if wire == nil || len(wire.RawJSON) != len(payload) {
|
||||||
|
t.Fatalf("large Gemini wire payload was truncated: %+v", wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeHTTPResponseForProtocolRejectsOversizedDefaultPayload(t *testing.T) {
|
||||||
|
payload := `{"data":"` + strings.Repeat("a", int(defaultMaxJSONResponseBytes)) + `"}`
|
||||||
|
response := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": {"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(payload)),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := decodeHTTPResponseForProtocol(response, ProtocolOpenAIResponses)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected oversized default response to fail")
|
||||||
|
}
|
||||||
|
clientErr, ok := err.(*ClientError)
|
||||||
|
if !ok || clientErr.Code != "response_too_large" {
|
||||||
|
t.Fatalf("unexpected oversized response error: %T %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGeminiNativeStreamPreservesOfficialEvents(t *testing.T) {
|
func TestGeminiNativeStreamPreservesOfficialEvents(t *testing.T) {
|
||||||
var requestedPath string
|
var requestedPath string
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType
|
|||||||
CASE
|
CASE
|
||||||
WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL
|
WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL
|
||||||
THEN jsonb_set(
|
THEN jsonb_set(
|
||||||
COALESCE(m.capabilities, '{}'::jsonb),
|
COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb),
|
||||||
'{text_generate,supportedApiProtocols}',
|
'{text_generate,supportedApiProtocols}',
|
||||||
b.capabilities #> '{text_generate,supportedApiProtocols}',
|
b.capabilities #> '{text_generate,supportedApiProtocols}',
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
ELSE m.capabilities
|
ELSE COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb)
|
||||||
END AS effective_capabilities,
|
END AS effective_capabilities,
|
||||||
m.capability_override,
|
m.capability_override,
|
||||||
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
|
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
-- Finalize the public/runtime contract for the three image-model migration.
|
||||||
|
-- Callers use stable official Gemini IDs while provider bindings retain their
|
||||||
|
-- upstream preview IDs. Capabilities and pricing are copied from the source
|
||||||
|
-- EasyAI runtime contract instead of falling back to generic defaults.
|
||||||
|
WITH model_contract(
|
||||||
|
invocation_name,
|
||||||
|
preview_invocation_name,
|
||||||
|
capabilities,
|
||||||
|
billing_config,
|
||||||
|
pricing_rule_set_key
|
||||||
|
) AS (
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'gemini-3-pro-image',
|
||||||
|
'gemini-3-pro-image-preview',
|
||||||
|
'{
|
||||||
|
"image_generate": {
|
||||||
|
"output_multiple_images": true,
|
||||||
|
"output_max_images_count": 4,
|
||||||
|
"output_resolutions": ["1K", "2K", "4K"],
|
||||||
|
"aspect_ratio_allowed": ["1:1", "16:9", "4:3", "3:4", "5:4", "4:5", "9:16", "21:9", "3:2", "2:3", "adaptive"]
|
||||||
|
},
|
||||||
|
"image_edit": {
|
||||||
|
"output_multiple_images": true,
|
||||||
|
"input_multiple_images": true,
|
||||||
|
"input_max_images_count": 14,
|
||||||
|
"input_max_file_size_bytes": 20971520,
|
||||||
|
"output_resolutions": ["1K", "2K", "4K"],
|
||||||
|
"aspect_ratio_allowed": ["1:1", "16:9", "4:3", "3:4", "5:4", "4:5", "9:16", "21:9", "3:2", "2:3", "adaptive"]
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_generate", "image_edit"]
|
||||||
|
}'::jsonb,
|
||||||
|
'{
|
||||||
|
"image": {
|
||||||
|
"basePrice": 1,
|
||||||
|
"baseWeight": 1,
|
||||||
|
"dynamicWeight": {
|
||||||
|
"1K": 1,
|
||||||
|
"2K": 1,
|
||||||
|
"3K": 1,
|
||||||
|
"4K": 1.8,
|
||||||
|
"8K": 4,
|
||||||
|
"qualityLow": 0.5,
|
||||||
|
"qualityMedium": 1,
|
||||||
|
"qualityHigh": 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'::jsonb,
|
||||||
|
'migration-pricing-f721f8076252bd77'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'gemini-3.1-flash-image',
|
||||||
|
'gemini-3.1-flash-image-preview',
|
||||||
|
'{
|
||||||
|
"image_generate": {
|
||||||
|
"output_multiple_images": true,
|
||||||
|
"output_max_images_count": 4,
|
||||||
|
"output_resolutions": ["1K", "2K", "4K"],
|
||||||
|
"aspect_ratio_allowed": ["1:1", "16:9", "4:3", "3:4", "5:4", "4:5", "9:16", "21:9", "3:2", "2:3", "1:4", "4:1", "1:8", "8:1", "adaptive"]
|
||||||
|
},
|
||||||
|
"image_edit": {
|
||||||
|
"output_multiple_images": true,
|
||||||
|
"input_multiple_images": true,
|
||||||
|
"input_max_images_count": 14,
|
||||||
|
"input_max_file_size_bytes": 20971520,
|
||||||
|
"output_resolutions": ["1K", "2K", "4K"],
|
||||||
|
"aspect_ratio_allowed": ["1:1", "16:9", "4:3", "3:4", "5:4", "4:5", "9:16", "21:9", "3:2", "2:3", "1:4", "4:1", "1:8", "8:1", "adaptive"]
|
||||||
|
},
|
||||||
|
"originalTypes": ["image_generate", "image_edit"]
|
||||||
|
}'::jsonb,
|
||||||
|
'{
|
||||||
|
"image": {
|
||||||
|
"basePrice": 1,
|
||||||
|
"baseWeight": 1,
|
||||||
|
"dynamicWeight": {
|
||||||
|
"1K": 1,
|
||||||
|
"2K": 1,
|
||||||
|
"3K": 1,
|
||||||
|
"4K": 1.8,
|
||||||
|
"8K": 4,
|
||||||
|
"qualityLow": 0.5,
|
||||||
|
"qualityMedium": 1,
|
||||||
|
"qualityHigh": 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'::jsonb,
|
||||||
|
'migration-pricing-f721f8076252bd77'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
updated_base_models AS (
|
||||||
|
UPDATE base_model_catalog base_model
|
||||||
|
SET capabilities = contract.capabilities,
|
||||||
|
base_billing_config = contract.billing_config,
|
||||||
|
pricing_rule_set_id = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT rule_set.id
|
||||||
|
FROM model_pricing_rule_sets rule_set
|
||||||
|
WHERE rule_set.rule_set_key = contract.pricing_rule_set_key
|
||||||
|
AND rule_set.status = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
base_model.pricing_rule_set_id
|
||||||
|
),
|
||||||
|
metadata = COALESCE(base_model.metadata, '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'bindingStatus', 'validating',
|
||||||
|
'capabilitySource', 'server-main-model-runtime',
|
||||||
|
'pricingSource', contract.pricing_rule_set_key
|
||||||
|
),
|
||||||
|
status = 'active',
|
||||||
|
updated_at = now()
|
||||||
|
FROM model_contract contract
|
||||||
|
WHERE base_model.provider_key = 'gemini'
|
||||||
|
AND base_model.invocation_name = contract.invocation_name
|
||||||
|
RETURNING base_model.id, base_model.invocation_name, base_model.capabilities
|
||||||
|
)
|
||||||
|
UPDATE platform_models platform_model
|
||||||
|
SET capabilities = updated.capabilities,
|
||||||
|
updated_at = now()
|
||||||
|
FROM updated_base_models updated
|
||||||
|
WHERE platform_model.base_model_id = updated.id;
|
||||||
|
|
||||||
|
-- The preview catalog rows originated as customized import records, so the
|
||||||
|
-- generic lifecycle migration deliberately did not alter them. This migration
|
||||||
|
-- scopes the lifecycle change to the two migrated image identities.
|
||||||
|
WITH replacements(preview_invocation_name, invocation_name) AS (
|
||||||
|
VALUES
|
||||||
|
('gemini-3-pro-image-preview', 'gemini-3-pro-image'),
|
||||||
|
('gemini-3.1-flash-image-preview', 'gemini-3.1-flash-image')
|
||||||
|
),
|
||||||
|
preview_models AS (
|
||||||
|
UPDATE base_model_catalog preview
|
||||||
|
SET status = 'deprecated',
|
||||||
|
metadata = COALESCE(preview.metadata, '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'lifecycleStage', 'deprecated',
|
||||||
|
'replacementModel', replacements.invocation_name,
|
||||||
|
'retireAt', '2026-07-22',
|
||||||
|
'sourceUrl', 'https://ai.google.dev/gemini-api/docs/deprecations',
|
||||||
|
'verifiedAt', '2026-07-24'
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
FROM replacements
|
||||||
|
WHERE preview.provider_key = 'gemini'
|
||||||
|
AND preview.invocation_name = replacements.preview_invocation_name
|
||||||
|
RETURNING preview.id, preview.invocation_name
|
||||||
|
),
|
||||||
|
stable_models AS (
|
||||||
|
SELECT stable.id, stable.invocation_name, replacements.preview_invocation_name
|
||||||
|
FROM replacements
|
||||||
|
JOIN base_model_catalog stable
|
||||||
|
ON stable.provider_key = 'gemini'
|
||||||
|
AND stable.invocation_name = replacements.invocation_name
|
||||||
|
),
|
||||||
|
removed_conflicting_aliases AS (
|
||||||
|
DELETE FROM model_compatibility_aliases compatibility_alias
|
||||||
|
USING preview_models preview, stable_models stable
|
||||||
|
WHERE compatibility_alias.base_model_id = preview.id
|
||||||
|
AND stable.preview_invocation_name = preview.invocation_name
|
||||||
|
AND compatibility_alias.alias IN (
|
||||||
|
stable.invocation_name,
|
||||||
|
'gemini:' || preview.invocation_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
INSERT INTO model_compatibility_aliases (
|
||||||
|
base_model_id,
|
||||||
|
alias,
|
||||||
|
model_type,
|
||||||
|
active
|
||||||
|
)
|
||||||
|
SELECT stable.id, stable.preview_invocation_name, model_type.value, true
|
||||||
|
FROM stable_models stable
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
VALUES ('image_generate'), ('image_edit')
|
||||||
|
) AS model_type(value)
|
||||||
|
ON CONFLICT (base_model_id, alias, model_type) DO UPDATE
|
||||||
|
SET active = true,
|
||||||
|
expires_at = NULL,
|
||||||
|
updated_at = now();
|
||||||
Reference in New Issue
Block a user