diff --git a/apps/api/internal/clients/helpers.go b/apps/api/internal/clients/helpers.go index 00e2255..c7dfd12 100644 --- a/apps/api/internal/clients/helpers.go +++ b/apps/api/internal/clients/helpers.go @@ -13,6 +13,11 @@ import ( "time" ) +const ( + defaultMaxJSONResponseBytes int64 = 16 << 20 + mediaMaxJSONResponseBytes int64 = 128 << 20 +) + func credential(candidate map[string]any, keys ...string) string { for _, key := range keys { 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) { 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{ Protocol: strings.TrimSpace(protocol), StatusCode: resp.StatusCode, @@ -60,6 +70,26 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st if len(raw) > 0 { _ = 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 { return nil, wire, &ClientError{ Code: statusCodeName(resp.StatusCode), @@ -82,6 +112,15 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st 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 { if len(source) == 0 { return nil diff --git a/apps/api/internal/clients/wire_response_test.go b/apps/api/internal/clients/wire_response_test.go index 685fa37..d78d4e7 100644 --- a/apps/api/internal/clients/wire_response_test.go +++ b/apps/api/internal/clients/wire_response_test.go @@ -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) { var requestedPath string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/store/candidates.go b/apps/api/internal/store/candidates.go index 4d16ce0..4932d4c 100644 --- a/apps/api/internal/store/candidates.go +++ b/apps/api/internal/store/candidates.go @@ -43,12 +43,12 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType CASE WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL THEN jsonb_set( - COALESCE(m.capabilities, '{}'::jsonb), + COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb), '{text_generate,supportedApiProtocols}', b.capabilities #> '{text_generate,supportedApiProtocols}', true ) - ELSE m.capabilities + ELSE COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb) END AS effective_capabilities, m.capability_override, COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override, diff --git a/apps/api/migrations/0079_image_model_migration_contract.sql b/apps/api/migrations/0079_image_model_migration_contract.sql new file mode 100644 index 0000000..ecce7b7 --- /dev/null +++ b/apps/api/migrations/0079_image_model_migration_contract.sql @@ -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();