diff --git a/apps/api/internal/store/rate_limit_policy_migration_integration_test.go b/apps/api/internal/store/rate_limit_policy_migration_integration_test.go index 4cb8da4..4d8464f 100644 --- a/apps/api/internal/store/rate_limit_policy_migration_integration_test.go +++ b/apps/api/internal/store/rate_limit_policy_migration_integration_test.go @@ -118,3 +118,123 @@ WHERE id = '20000000-0000-0000-0000-000000000001'`).Scan(&normalizedPlatformPoli t.Fatalf("normalized platform policy=%s, want={}", normalizedPlatformPolicy) } } + +func TestRemoveUnconfiguredGPTImageConcurrencyLimitMigration(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run rate-limit migration PostgreSQL integration tests") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + schema := fmt.Sprintf("remove_gpt_image_limit_%d", time.Now().UnixNano()) + adminPool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect migration test database: %v", err) + } + defer adminPool.Close() + if _, err := adminPool.Exec(ctx, `CREATE SCHEMA `+schema); err != nil { + t.Fatalf("create migration test schema: %v", err) + } + defer adminPool.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) + + schemaURL, err := url.Parse(databaseURL) + if err != nil { + t.Fatalf("parse test database url: %v", err) + } + query := schemaURL.Query() + query.Set("search_path", schema) + schemaURL.RawQuery = query.Encode() + pool, err := pgxpool.New(ctx, schemaURL.String()) + if err != nil { + t.Fatalf("connect migration test schema: %v", err) + } + defer pool.Close() + if _, err := pool.Exec(ctx, ` +CREATE TABLE base_model_catalog ( + id uuid PRIMARY KEY, + invocation_name text NOT NULL, + default_rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO base_model_catalog (id, invocation_name, default_rate_limit_policy, metadata) VALUES + ( + '00000000-0000-0000-0000-000000000001', + 'gpt-image-2', + '{"rules":[{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}', + '{"rateLimitSource":"image-gateway-migration","rateLimitUpdatedAt":"2026-07-24"}' + ), + ( + '00000000-0000-0000-0000-000000000002', + 'gpt-image-2', + '{"rules":[{"metric":"concurrent","limit":6,"leaseTtlSeconds":300}]}', + '{"rateLimitSource":"image-gateway-migration"}' + ), + ( + '00000000-0000-0000-0000-000000000003', + 'gpt-image-2', + '{"rules":[{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}', + '{"rateLimitSource":"operator"}' + ), + ( + '00000000-0000-0000-0000-000000000004', + 'gemini-3-pro-image', + '{"rules":[{"metric":"concurrent","limit":10,"leaseTtlSeconds":600}]}', + '{"rateLimitSource":"image-gateway-migration"}' + );`); err != nil { + t.Fatalf("seed pre-migration rows: %v", err) + } + + _, currentFile, _, _ := runtime.Caller(0) + migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "0082_remove_unconfigured_gpt_image_concurrency_limit.sql") + migration, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("read migration: %v", err) + } + if _, err := pool.Exec(ctx, string(migration)); err != nil { + t.Fatalf("apply migration: %v", err) + } + + rows, err := pool.Query(ctx, ` +SELECT id::text, default_rate_limit_policy::text, metadata->>'rateLimitSource', + metadata->>'rateLimitRemovalReason' +FROM base_model_catalog +ORDER BY id`) + if err != nil { + t.Fatalf("read migrated rows: %v", err) + } + defer rows.Close() + type result struct { + policy string + source *string + removalReason *string + } + got := make([]result, 0, 4) + for rows.Next() { + var id string + var item result + if err := rows.Scan(&id, &item.policy, &item.source, &item.removalReason); err != nil { + t.Fatalf("scan migrated row: %v", err) + } + got = append(got, item) + } + if err := rows.Err(); err != nil { + t.Fatalf("read migrated rows: %v", err) + } + if len(got) != 4 { + t.Fatalf("migrated %d rows, want 4", len(got)) + } + if got[0].policy != "{}" || got[0].source != nil || + got[0].removalReason == nil || *got[0].removalReason != "upstream-limit-unconfigured" { + t.Fatalf("migration-owned GPT policy not removed: %+v", got[0]) + } + if got[1].policy == "{}" || got[1].source == nil || *got[1].source != "image-gateway-migration" { + t.Fatalf("custom GPT policy was changed: %+v", got[1]) + } + if got[2].policy == "{}" || got[2].source == nil || *got[2].source != "operator" { + t.Fatalf("operator GPT policy was changed: %+v", got[2]) + } + if got[3].policy == "{}" || got[3].source == nil || *got[3].source != "image-gateway-migration" { + t.Fatalf("Gemini policy was changed: %+v", got[3]) + } +} diff --git a/apps/api/migrations/0082_remove_unconfigured_gpt_image_concurrency_limit.sql b/apps/api/migrations/0082_remove_unconfigured_gpt_image_concurrency_limit.sql new file mode 100644 index 0000000..b0c0ea3 --- /dev/null +++ b/apps/api/migrations/0082_remove_unconfigured_gpt_image_concurrency_limit.sql @@ -0,0 +1,18 @@ +-- gpt-image-2 platforms do not declare an upstream concurrency limit. The +-- migration-only base default added in 0081 therefore turned an unspecified +-- limit into an artificial per-platform-model cap of five. +-- +-- Remove only the exact policy written by 0081. Explicit platform, runtime, or +-- platform-model policies continue to take precedence and remain untouched. +UPDATE base_model_catalog +SET default_rate_limit_policy = '{}'::jsonb, + metadata = (COALESCE(metadata, '{}'::jsonb) - 'rateLimitSource' - 'rateLimitUpdatedAt') + || jsonb_build_object( + 'rateLimitRemovedAt', '2026-07-24', + 'rateLimitRemovalReason', 'upstream-limit-unconfigured' + ), + updated_at = now() +WHERE invocation_name = 'gpt-image-2' + AND metadata->>'rateLimitSource' = 'image-gateway-migration' + AND default_rate_limit_policy = + '{"rules":[{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb;