diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 111ce45bc5aa..c4e3c903382d 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -1,7 +1,7 @@ # Native domains -Six first-party domains run inside the host: Codex runtime, model policy, -system prompts, account tools, image tools and observability. Native pages +Five first-party domains run inside the host: Codex runtime, model policy, +system prompts, account tools and observability. Native pages retain the console theme (flat_theme_enabled), account fields and persisted task interactions. The official third-party plugin framework and its own configuration UI remain. @@ -13,10 +13,9 @@ authentication and the existing step-up policy. | Interface | Contract | | --- | --- | | `GET/PUT /admin/settings/codex-runtime` | Codex request body compression switch (`request_zstd`; a missing key is on). | -| `GET/PUT /admin/settings/image-tools` | Image Studio switch (`studio_enabled`; a missing key is off). | | `GET/PUT /admin/settings/observability` | Telemetry and native theme switches. | -Each of the three is stored as one `settings` row holding a JSON object of +Each of the two is stored as one `settings` row holding a JSON object of boolean switches; `GET` and `PUT` answer with the switches in the standard response envelope. A `PUT` body that is not a JSON object, or that carries any other key or a non-boolean value (including `null`), returns HTTP 400 and stores @@ -36,14 +35,14 @@ saved Codex runtime configuration has no hash recorded by v0.2.11-codexrip.6, key. Starting one of those three releases once satisfies the first two conditions. -Then, before native settings load and before Image Studio or the plugin manager -starts, one transaction retires the other first-party plugin installations a -database still holds: +Then, before native settings load and before the plugin manager starts, one +transaction retires the other first-party plugin installations a database still +holds, except `codexrip.image-tools`, which it does not read or change: 1. Validates the saved first-party capability scopes and decrypts configuration. -2. Imports the effective image and observability switches only when their native - settings keys are absent. Existing keys are never overwritten. The retired - Cindy provider installation is disabled without importing its switches. +2. Imports the effective observability switches only when the native settings + key is absent. An existing key is never overwritten. The retired Cindy + provider installation is disabled without importing its switches. 3. Saves original installation/binding/bootstrap intent under `deplugin_retired_plugins`, disables the installations and their bindings, and marks their bootstrap records removed. @@ -79,6 +78,24 @@ continues. Images at or before v0.2.11-codexrip.5 also need `sub2api_plugin_state` and `sub2api_plugin_leases` for their route code and require a database dump taken before migration 264. +Migration 270 has no down path either. It drops the three Image Studio tables +with the job history and the records of the stored image files and deletes the +`image_tools_config` setting; the files under `/image-studio` are not +removed. An earlier image starts on such a database only with Image Studio off, +which the missing setting leaves to `GATEWAY_IMAGE_STUDIO_ENABLED`: with that +variable `true` it exits at start, and its Image Studio job routes fail on the +dropped tables. + +Migration 271 has no down path. It drops `usage_billing_dedup.account_id`, which +the billing claim no longer writes and nothing reads. An earlier image names +that column in every billing claim, so its usage billing fails on such a +database until the column is added again +(`ALTER TABLE usage_billing_dedup ADD COLUMN account_id BIGINT`). Migration 271 +is already recorded by then and does not run a second time: after returning to +this release or a later one, drop the column by hand +(`ALTER TABLE usage_billing_dedup DROP COLUMN IF EXISTS account_id`). Billing +works with the column present until that is done. + Returning to a plugin-based host is not an image change. That host needs schema objects and plugin data the current database no longer holds, so it requires a database dump that still has them. Such a legacy-host rollback has not been run diff --git a/.downstream/refresh-coverage.mjs b/.downstream/refresh-coverage.mjs index a8b510c0d055..2cbb9524eaf4 100644 --- a/.downstream/refresh-coverage.mjs +++ b/.downstream/refresh-coverage.mjs @@ -48,12 +48,12 @@ if (missing.length) { function ownerOf(file) { if (file.startsWith('plugins/')) return file.split('/')[1] === 'bundle.source.json' ? 'core-extension-and-job-host' : file.split('/')[1] - if (/\/plugin[^/]*\.(?:go|ts|vue)$|\/plugins\/|backend\/pkg\/(?:extensionapi|pluginapi)\/|internal\/testextensions\//.test(file)) return 'core-extension-and-job-host' + if (/\/plugin[^/]*\.(?:go|ts|vue)$|\/plugins\/|backend\/pkg\/(?:extensionapi|pluginapi)\//.test(file)) return 'core-extension-and-job-host' if (/^\.github\/|^Dockerfile|^deploy\/|^\.dockerignore$|^\.gitignore$|refresh-coverage\.mjs$/.test(file)) return 'operations' if (/remote_skill|business_system_prompt|systemPrompt|SystemPrompt/.test(file)) return 'prompt-skills' if (/^\.downstream\/|^\.superpowers\/|\.md$/.test(file)) return 'documentation-and-history' if (/cindy/i.test(file)) return 'cindy-provider' - if (/image_studio|ImageStudio|codex_image_generation_bridge/.test(file)) return 'image-tools' + if (/image_studio|codex_image_generation_bridge/.test(file)) return 'image-tools' if (/account_traffic_observe|styles\/theme\.css|tailwind\.config/.test(file)) return 'admin-observability' if (/account_quota|quota_estimate|quota_activity/.test(file)) return 'core-quota-and-protocol' return priorOwners.get(file) || 'mixed-requires-review' diff --git a/.downstream/upstream-review-v0.2.13.md b/.downstream/upstream-review-v0.2.13.md index 8a1501ae1fcf..8f6b767df355 100644 --- a/.downstream/upstream-review-v0.2.13.md +++ b/.downstream/upstream-review-v0.2.13.md @@ -98,7 +98,7 @@ Every official commit is adopted with its official behaviour. Where upstream and - the Codex models manifest selects through the scheduler as well, where upstream uses `SelectAccountForModelWithExclusions`, which installs no gate; its handler sets the suppress marker before the selection loop, so the manifest stays outside the gate as upstream (`TestCodexModelsIgnoresGroupProfitControl`); - the eligibility helpers return `(bool, reason)` for the downstream selection diagnostics, and the handlers pass the pricing instant through the downstream usage snapshot; - the auth snapshot version is the downstream counter (28; upstream 24). -- Image Studio and the Responses image bridge stay off, the public model page stays retired and reasoning recovery stays a downstream addition (one global switch, see Integration decisions); no upstream change in this range touches them. +- The public model page stays retired and reasoning recovery stays a downstream addition (one global switch, see Integration decisions); no upstream change in this range touches them. ## Conflict resolutions diff --git a/.github/scripts/test_native_domains.sh b/.github/scripts/test_native_domains.sh index 220c6f41ab80..d95a428931d5 100644 --- a/.github/scripts/test_native_domains.sh +++ b/.github/scripts/test_native_domains.sh @@ -4,13 +4,12 @@ set -euo pipefail repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) cd "$repo_root" -# The same domain policies now compile into the host; no package/signing process. +# The Codex runtime domain packages. go -C backend test -p=1 -tags unit \ - ./internal/accounttools/... \ ./internal/codexruntime/... \ -count=1 # Preserve configuration/storage boundaries without starting real IO. go -C backend test -p=1 -tags unit ./internal/service ./internal/repository \ - -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestImageTools.*|TestSwitchingImageStudio(OnStartsTheRuntime|OffStopsDetachedUpstreamIO)|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ + -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ -count=1 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 30dc1f236665..3ceab3f9c19f 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -42,7 +42,7 @@ jobs: run: bash .github/scripts/test_model_context_capacity.sh - name: Service tests working-directory: backend - run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|AlphaSearch|SelectedWorkspace)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1 + run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|AlphaSearch|SelectedWorkspace)|Test(UpdateService|NormalizeAccountTaxonomyName|AccountTaxonomyRules|AccountTestReasoningValidated|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1 - name: Upstream automation classifier tests run: | python3 .github/scripts/test_upstream_risk.py @@ -53,13 +53,10 @@ jobs: - name: Gateway management isolation race test working-directory: backend run: go test -race ./internal/handler -run '^TestOpenAIBlockedUpstreamsDoNotSerializeManagementPlane$' -count=1 - - name: Native policy cancellation race test - working-directory: backend - run: go test -p=1 -race -tags unit ./internal/service -run '^TestNativePolicyCancellationSurvivesDetachedClientContext$' -count=1 - name: Image, model list, and turn-state tests working-directory: backend run: | - go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(ImageStudioEligibleKeys|CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|SchedulerCache|SchedulerRefresh|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 + go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|SchedulerCache|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 go test -p=1 -tags unit ./migrations -run 'Test.*(Migration222|Migration223|Migration224|Migration230|Migration231|Migration232|Migration235|Migration236|GroupModelAllowlistRepairMigration|ReleaseAcceptance)' -count=1 - name: Request body recovery tests working-directory: backend @@ -119,7 +116,6 @@ jobs: src/components/common/__tests__/VersionBadge.downstream.spec.ts \ src/components/common/__tests__/BaseDialog.spec.ts \ src/components/common/__tests__/Select.disabled.spec.ts \ - src/utils/__tests__/imageStudioLocalResources.spec.ts \ src/views/admin/__tests__/PluginsView.spec.ts \ src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts \ src/api/__tests__/downstreamStatus.spec.ts \ @@ -129,7 +125,6 @@ jobs: src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \ src/stores/__tests__/accountJobs.spec.ts \ src/api/__tests__/accountOperationIdempotency.spec.ts \ - src/features/image-studio/history.legacy.spec.ts \ src/components/admin/account/__tests__/AccountFolderBar.spec.ts \ src/components/admin/account/__tests__/AccountTaxonomyManager.spec.ts \ src/components/admin/account/__tests__/AccountBulkTaxonomyModal.spec.ts \ @@ -157,11 +152,9 @@ jobs: src/views/admin/__tests__/AccountsView.console.spec.ts \ src/utils/__tests__/accountKeySearch.spec.ts \ src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts \ - src/features/image-studio/__tests__/api.spec.ts \ src/components/layout/__tests__/AppSidebar.spec.ts \ src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts \ src/views/admin/__tests__/GroupsView.columnSettings.spec.ts \ - src/features/image-studio/__tests__/ImageStudioPage.spec.ts \ src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts \ src/views/admin/__tests__/AccountsView.selectionScope.spec.ts \ src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts \ diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index c4ff21441323..753bf8391770 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -10,7 +10,7 @@ This fork maintains the `codexrip` patch set over official Sub2API releases. ## Native domains and account operations -The seven first-party domains now run inside the host and use native Vue pages. +The five first-party domains run inside the host and use native Vue pages. The official third-party plugin framework remains available. Domain settings, startup data preservation and the legacy-host rollback boundary are described in [native domains](.downstream/native-domains.md). @@ -45,9 +45,8 @@ only that account/model. The account option `openai_prompt_cache_key_mode` 64 characters with its SHA-256 hex. Continuation, refusal recovery and destination-specific reasoning summaries remain supported. Account test model choices persist; API-key reveal requires the configured password. The account test -dialog keeps list position. Image Studio defaults off and lists no eligible key -until a generic image model source exists. Public model management remains -retired; catalog changes do not restore its routes or navigation. +dialog keeps list position. Public model management remains retired; catalog +changes do not restore its routes or navigation. Quota storage, aggregation, cleanup and resets follow official semantics. Missing quota rows represent unlimited access; rows with three NULL limits are purged by diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b138f9b3a622..9caac8fe13b7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -232,21 +232,6 @@ func runMainServer() { os.Exit(1) } } - if app.ImageStudio != nil { - studio := app.ImageStudio - service.SetImageStudioStarter(func() { - if err := studio.Start(context.Background()); err != nil { - log.Printf("Image Studio could not start: %v", err) - } - }) - } - if app.ImageStudio != nil && service.ImageStudioFeatureEnabled() { - if err := app.ImageStudio.Start(context.Background()); err != nil { - log.Printf("Image Studio could not start: %v", err) - app.Cleanup() - os.Exit(1) - } - } // 启动服务器 go func() { diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 65d2e98e5929..15caf8197481 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -31,7 +31,6 @@ type Application struct { PromptAudit *securityaudit.PromptService PluginManager *service.PluginManager AccountJobs *service.AccountJobRuntime - ImageStudio *service.ImageStudioRuntime CodexIdentity *service.CodexClientIdentityBackfillService Cleanup func() } @@ -64,7 +63,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { provideCleanup, // Application struct - wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "ImageStudio", "CodexIdentity", "Cleanup"), + wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "CodexIdentity", "Cleanup"), ) return nil, nil } @@ -145,7 +144,6 @@ func provideCleanup( openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, accountJobs *service.AccountJobRuntime, - imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, ) func() { return func() { @@ -158,12 +156,6 @@ func provideCleanup( // 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。 parallelSteps := []cleanupStep{ - {"ImageStudioRuntime", func() error { - if imageStudioRuntime != nil { - return imageStudioRuntime.Stop(ctx) - } - return nil - }}, {"AccountJobRuntime", func() error { if accountJobs != nil { accountJobs.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index f23ab8e9d160..1209a11f84b2 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -348,12 +348,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig) batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig) batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) - imageStudioRepository := repository.NewImageStudioRepository(db) - imageStudioArtifactStore := service.ProvideImageStudioArtifactStore(configConfig, imageStudioRepository) - imageStudioService := service.NewImageStudioService(imageStudioRepository, apiKeyRepository, imageStudioArtifactStore) - imageStudioJobHandler := handler.NewImageStudioJobHandler(imageStudioService) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, imageStudioJobHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -363,8 +359,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) accountJobRuntime := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) - imageStudioGatewayExecutor := handler.NewImageStudioGatewayExecutor(openAIGatewayHandler, subscriptionService, opsService) - imageStudioRuntime := handler.ProvideImageStudioRuntime(imageStudioRepository, imageStudioService, imageStudioArtifactStore, imageStudioGatewayExecutor) codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) @@ -387,13 +381,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) - v := provideCleanup(client, redisClient, gatewayCache, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, imageStudioRuntime, pluginManager) + v := provideCleanup(client, redisClient, gatewayCache, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, PluginManager: pluginManager, AccountJobs: accountJobRuntime, - ImageStudio: imageStudioRuntime, CodexIdentity: codexClientIdentityBackfillService, Cleanup: v, } @@ -407,7 +400,6 @@ type Application struct { PromptAudit *securityaudit.PromptService PluginManager *service.PluginManager AccountJobs *service.AccountJobRuntime - ImageStudio *service.ImageStudioRuntime CodexIdentity *service.CodexClientIdentityBackfillService Cleanup func() } @@ -488,7 +480,6 @@ func provideCleanup( openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, accountJobs *service.AccountJobRuntime, - imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, ) func() { return func() { @@ -500,12 +491,6 @@ func provideCleanup( } parallelSteps := []cleanupStep{ - {"ImageStudioRuntime", func() error { - if imageStudioRuntime != nil { - return imageStudioRuntime.Stop(ctx) - } - return nil - }}, {"AccountJobRuntime", func() error { if accountJobs != nil { accountJobs.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 23be0a8b7676..89253e8099c6 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -111,7 +111,6 @@ func minimalDependencyCleanup(gatewayCache service.GatewayCache, autoReset *serv autoReset, nil, // promptAudit nil, // accountJobRuntime - nil, // imageStudioRuntime nil, // pluginManager ) } diff --git a/backend/go.mod b/backend/go.mod index 1f6067256ce3..0ce1a163b854 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -31,7 +31,6 @@ require ( github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/pquerna/otp v1.5.0 github.com/redis/go-redis/v9 v9.22.0 github.com/refraction-networking/utls v1.8.2 @@ -166,6 +165,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect diff --git a/backend/internal/accounttools/policy/import.go b/backend/internal/accounttools/policy/import.go deleted file mode 100644 index 6a19e862f676..000000000000 --- a/backend/internal/accounttools/policy/import.go +++ /dev/null @@ -1,26 +0,0 @@ -package policy - -import extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - -func planImport(in extensionv1.AccountImportPlanningRequest) []extensionv1.AccountImportItemPlan { - if in.Phase == "finalize" { - // Items are planned independently; the prepared chunk plans are final. - return append([]extensionv1.AccountImportItemPlan(nil), in.Prepared...) - } - out := make([]extensionv1.AccountImportItemPlan, len(in.Items)) - for index, facts := range in.Items { - plan := extensionv1.AccountImportItemPlan{Index: index} - switch { - case !facts.PayloadValid: - plan.Action, plan.Code = "reject", extensionv1.AccountImportCodePayloadInvalid - case len(facts.Matches) == 0: - plan.Action, plan.Code = "create", extensionv1.AccountImportCodeCreate - case len(facts.Matches) == 1: - plan.Action, plan.Code, plan.AccountID = "update", extensionv1.AccountImportCodeUpdate, facts.Matches[0] - default: - plan.Action, plan.Code = "reject", extensionv1.AccountImportCodeIdentityConflict - } - out[index] = plan - } - return out -} diff --git a/backend/internal/accounttools/policy/import_test.go b/backend/internal/accounttools/policy/import_test.go deleted file mode 100644 index 25956f82727a..000000000000 --- a/backend/internal/accounttools/policy/import_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package policy - -import ( - "context" - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestImportPolicyPlansEachItemByIdentityMatches(t *testing.T) { - request := extensionv1.AccountImportPlanningRequest{Items: []extensionv1.AccountImportItemFacts{ - {PayloadValid: true}, - {PayloadValid: true, Matches: []int64{7}}, - {PayloadValid: true, Matches: []int64{7, 8}}, - {PayloadValid: false, Matches: []int64{7}}, - }} - plans := planImport(request) - if plans[0].Action != "create" || plans[0].Code != extensionv1.AccountImportCodeCreate { - t.Fatalf("unmatched item was not created: %+v", plans[0]) - } - if plans[1].Action != "update" || plans[1].AccountID != 7 { - t.Fatalf("single identity match was not updated: %+v", plans[1]) - } - if plans[2].Code != extensionv1.AccountImportCodeIdentityConflict { - t.Fatal("ambiguous identity was accepted") - } - if plans[3].Code != extensionv1.AccountImportCodePayloadInvalid || plans[3].AccountID != 0 { - t.Fatal("invalid payload was accepted") - } - request.Phase = "finalize" - raw, _ := json.Marshal(request) - if _, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: "import.plan", Payload: raw}); err == nil { - t.Fatal("incomplete prepared phase was accepted") - } -} diff --git a/backend/internal/accounttools/policy/module.go b/backend/internal/accounttools/policy/module.go deleted file mode 100644 index 97ab5c219ebc..000000000000 --- a/backend/internal/accounttools/policy/module.go +++ /dev/null @@ -1,168 +0,0 @@ -package policy - -import ( - "context" - "encoding/json" - "errors" - "slices" - "strings" - "unicode/utf8" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -type Module struct{} - -func New() *Module { return &Module{} } - -func failure(code, message string, status int) extensionv1.Result { - return extensionv1.Result{Code: code, Message: message, HTTPStatus: status} -} -func strictIDs(ids []int64) bool { - seen := map[int64]bool{} - for _, id := range ids { - if id <= 0 || seen[id] { - return false - } - seen[id] = true - } - return true -} -func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if err := ctx.Err(); err != nil { - return extensionv1.Result{}, err - } - var output any = map[string]bool{"valid": true} - switch in.Operation { - case "import.plan": - var request extensionv1.AccountImportPlanningRequest - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid import facts") - } - if (request.Phase != "" && request.Phase != "prepare" && request.Phase != "finalize") || (request.Phase == "finalize" && len(request.Prepared) != len(request.Items)) { - return extensionv1.Result{}, errors.New("invalid import phase") - } - output = planImport(request) - case "test.prompt": - var request extensionv1.TextPromptSelection - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid test prompt metadata") - } - if !request.ValidUTF8 || request.Characters < 0 || request.Characters > 8192 { - return failure("ACCOUNT_TEST_PROMPT_INVALID", "test prompt must be valid UTF-8 and at most 8192 characters", 400), nil - } - case "taxonomy.name": - var request extensionv1.TaxonomyName - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid taxonomy name payload") - } - request.Name = strings.TrimSpace(request.Name) - if !utf8.ValidString(request.Name) || request.Name == "" || len([]rune(request.Name)) > 100 || strings.ContainsRune(request.Name, '\x00') { - return failure("ACCOUNT_TAXONOMY_NAME_INVALID", "name must contain between 1 and 100 characters", 400), nil - } - request.Normalized = strings.ToLower(request.Name) - output = request - case "taxonomy.order": - var request extensionv1.TaxonomyOrder - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid taxonomy order payload") - } - if len(request.Actual) != len(request.Ordered) { - return failure("ACCOUNT_TAXONOMY_ORDER_CHANGED", "account taxonomy changed; reload and try again", 409), nil - } - if !strictIDs(request.Ordered) { - return failure("ACCOUNT_TAXONOMY_ORDER_INVALID", "ordered_ids must contain positive unique IDs", 400), nil - } - for _, id := range request.Actual { - if !slices.Contains(request.Ordered, id) { - return failure("ACCOUNT_TAXONOMY_ORDER_CHANGED", "account taxonomy changed; reload and try again", 409), nil - } - } - case "taxonomy.bulk": - var request extensionv1.TaxonomyBulkPlan - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid taxonomy bulk payload") - } - if (len(request.AccountIDs) > 0) == request.HasFilters { - return failure("ACCOUNT_TAXONOMY_TARGET_INVALID", "provide exactly one of account_ids or filters", 400), nil - } - if !strictIDs(request.AccountIDs) { - return failure("ACCOUNT_TAXONOMY_ACCOUNT_IDS_INVALID", "IDs must be positive and unique", 400), nil - } - if !strictIDs(request.TagAddIDs) || !strictIDs(request.TagRemoveIDs) { - return failure("ACCOUNT_TAXONOMY_TAG_IDS_INVALID", "IDs must be positive and unique", 400), nil - } - for _, id := range request.TagAddIDs { - if slices.Contains(request.TagRemoveIDs, id) { - return failure("ACCOUNT_TAXONOMY_TAG_OPERATION_CONFLICT", "a tag cannot be added and removed in the same request", 400), nil - } - } - switch request.FolderAction { - case "": - if request.FolderID != nil { - return failure("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_id requires folder_action=set", 400), nil - } - case "set": - if request.FolderID == nil || *request.FolderID <= 0 { - return failure("ACCOUNT_TAXONOMY_FOLDER_ID_INVALID", "folder_id must be positive when folder_action=set", 400), nil - } - case "clear": - if request.FolderID != nil { - return failure("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_id must be omitted when folder_action=clear", 400), nil - } - default: - return failure("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_action must be set, clear, or omitted", 400), nil - } - if request.FolderAction == "" && len(request.TagAddIDs) == 0 && len(request.TagRemoveIDs) == 0 { - return failure("ACCOUNT_TAXONOMY_OPERATION_REQUIRED", "at least one taxonomy operation is required", 400), nil - } - if request.HasFilters && (request.ExpectedMatchCount == nil || *request.ExpectedMatchCount < 0) { - return failure("ACCOUNT_TAXONOMY_EXPECTED_COUNT_REQUIRED", "expected_match_count is required for filter targets", 400), nil - } - case "taxonomy.assignment": - var request extensionv1.TaxonomyAssignmentPlan - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid taxonomy assignment payload") - } - if request.FolderID != nil && *request.FolderID <= 0 { - return failure("ACCOUNT_FOLDER_ID_INVALID", "folder_id must be positive or null", 400), nil - } - seen := map[int64]bool{} - tags := make([]int64, 0, len(request.TagIDs)) - for _, id := range request.TagIDs { - if id <= 0 { - return failure("ACCOUNT_TAG_ID_INVALID", "tag_ids must contain positive IDs", 400), nil - } - if !seen[id] { - seen[id] = true - tags = append(tags, id) - } - } - request.TagIDs = tags - output = request - case "taxonomy.delete": - var request struct { - ID int64 `json:"id"` - } - if json.Unmarshal(in.Payload, &request) != nil || request.ID <= 0 { - return failure("ACCOUNT_TAXONOMY_ID_INVALID", "ID must be positive", 400), nil - } - case "test.reasoning": - var request extensionv1.ReasoningSelection - if json.Unmarshal(in.Payload, &request) != nil { - return extensionv1.Result{}, errors.New("invalid reasoning selection") - } - if request.Effort != "" { - if request.Effort != strings.TrimSpace(request.Effort) || len(request.Effort) > 32 || (request.Mode != "" && request.Mode != "default" && request.Mode != "text") { - return failure("TEST_REASONING_MODE_UNSUPPORTED", "reasoning effort is unsupported for this test mode", 400), nil - } - if !slices.Contains(request.Levels, request.Effort) { - return failure("TEST_REASONING_EFFORT_UNSUPPORTED", "reasoning effort is not supported by the selected account model", 400), nil - } - } - default: - return extensionv1.Result{}, errors.New("unsupported account tools operation") - } - raw, err := json.Marshal(output) - return extensionv1.Result{Payload: raw}, err -} diff --git a/backend/internal/accounttools/policy/module_test.go b/backend/internal/accounttools/policy/module_test.go deleted file mode 100644 index 2594249ebb4a..000000000000 --- a/backend/internal/accounttools/policy/module_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package policy - -import ( - "context" - "encoding/json" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestTaxonomyPoliciesRejectAmbiguityAndInvalidAssignments(t *testing.T) { - for _, tc := range []struct{ operation, payload, code string }{ - {"taxonomy.name", `{"name":" Production "}`, ""}, - {"taxonomy.name", `{"name":"bad\u0000name"}`, "ACCOUNT_TAXONOMY_NAME_INVALID"}, - {"taxonomy.order", `{"actual":[1,2],"ordered":[2,1]}`, ""}, - {"taxonomy.order", `{"actual":[1,2],"ordered":[1,1]}`, "ACCOUNT_TAXONOMY_ORDER_INVALID"}, - {"taxonomy.order", `{"actual":[1,2,3],"ordered":[2,1]}`, "ACCOUNT_TAXONOMY_ORDER_CHANGED"}, - {"taxonomy.bulk", `{"account_ids":[1],"has_filters":true,"folder_action":"clear"}`, "ACCOUNT_TAXONOMY_TARGET_INVALID"}, - {"taxonomy.bulk", `{"account_ids":[1],"tag_add_ids":[2],"tag_remove_ids":[2]}`, "ACCOUNT_TAXONOMY_TAG_OPERATION_CONFLICT"}, - {"taxonomy.bulk", `{"has_filters":true,"folder_action":"clear"}`, "ACCOUNT_TAXONOMY_EXPECTED_COUNT_REQUIRED"}, - {"taxonomy.assignment", `{"tag_ids":[-1]}`, "ACCOUNT_TAG_ID_INVALID"}, - {"taxonomy.assignment", `{"tag_ids":[1,1,2]}`, ""}, - } { - out, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: tc.operation, Payload: json.RawMessage(tc.payload)}) - if err != nil || out.Code != tc.code { - t.Fatalf("%s: %v %+v", tc.operation, err, out) - } - } -} - -func TestReasoningSelectionValidatesTheSuppliedWireCapabilities(t *testing.T) { - for _, tc := range []struct { - mode, effort string - valid bool - }{{"default", "ultra", true}, {"text", "ultra", true}, {"compact", "ultra", false}, {"default", " high ", false}, {"default", "high", false}, {"default", "", true}} { - raw, _ := json.Marshal(extensionv1.ReasoningSelection{Mode: tc.mode, Effort: tc.effort, Levels: []string{"ultra"}}) - out, err := New().Invoke(context.Background(), extensionv1.Invocation{Operation: "test.reasoning", Payload: raw}) - if err != nil || (out.Code == "") != tc.valid { - t.Fatalf("%s/%s: %v %+v", tc.mode, tc.effort, err, out) - } - } -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2e2ff1d8711a..5c9f8fdf97db 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2726,9 +2726,6 @@ func setEnvReachableDefaults() { } func (c *Config) Validate() error { - if _, err := ResolveImageStudioEnabledFromEnvironment(); err != nil { - return err - } forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders) if err != nil { return fmt.Errorf("security.forwarded_client_ip_headers: %w", err) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 441442270f4a..13be1c98f983 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -79,41 +79,6 @@ func TestLoadServerTimingConfig(t *testing.T) { }) } -func TestResolveImageStudioEnabledFromEnvironment(t *testing.T) { - tests := []struct { - name string - primary string - want bool - wantErrEnv string - }{ - {name: "unset", primary: "", want: false}, - {name: "primary", primary: "true", want: true}, - {name: "disabled", primary: "false", want: false}, - {name: "invalid primary", primary: "sometimes", wantErrEnv: ImageStudioEnabledEnv}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Setenv(ImageStudioEnabledEnv, tt.primary) - got, err := ResolveImageStudioEnabledFromEnvironment() - if tt.wantErrEnv != "" { - require.ErrorContains(t, err, tt.wantErrEnv) - return - } - require.NoError(t, err) - require.Equal(t, tt.want, got) - }) - } -} - -func TestLoadRejectsInvalidImageStudioEnvironment(t *testing.T) { - resetViperWithJWTSecret(t) - t.Setenv(ImageStudioEnabledEnv, "not-a-boolean") - - _, err := LoadForBootstrap() - require.ErrorContains(t, err, ImageStudioEnabledEnv+" must be a boolean") -} - func TestLoadSimpleModeKeyRateLimitEnabledFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("SIMPLE_MODE_KEY_RATE_LIMIT_ENABLED", "true") diff --git a/backend/internal/config/image_studio_feature_flags.go b/backend/internal/config/image_studio_feature_flags.go deleted file mode 100644 index 18c9137e24d1..000000000000 --- a/backend/internal/config/image_studio_feature_flags.go +++ /dev/null @@ -1,32 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" -) - -const ImageStudioEnabledEnv = "GATEWAY_IMAGE_STUDIO_ENABLED" - -// ResolveImageStudioEnabledFromEnvironment resolves the Image Studio flag. An -// empty value is treated as absent because the bundled Compose files inject -// the variable with an empty default. -func ResolveImageStudioEnabledFromEnvironment() (bool, error) { - enabled, _, err := optionalBooleanEnvironment(ImageStudioEnabledEnv) - return enabled, err -} - -func optionalBooleanEnvironment(name string) (value bool, configured bool, err error) { - raw, ok := os.LookupEnv(name) - if !ok || strings.TrimSpace(raw) == "" { - return false, false, nil - } - switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "true", "yes", "on": - return true, true, nil - case "0", "false", "no", "off": - return false, true, nil - default: - return false, true, fmt.Errorf("%s must be a boolean", name) - } -} diff --git a/backend/internal/handler/admin/account_data_identity.go b/backend/internal/handler/admin/account_data_identity.go index e9611cdf436f..a613663440dd 100644 --- a/backend/internal/handler/admin/account_data_identity.go +++ b/backend/internal/handler/admin/account_data_identity.go @@ -16,8 +16,6 @@ const ( type DataImportIdentityMatch struct { AccountID int64 - Name string - MatchedBy string } type dataIdentityKey struct { @@ -171,9 +169,7 @@ func (index *dataIdentityIndex) Find(keys []dataIdentityKey) []DataImportIdentit if dataAccountIdentityUserConflicts(key.Label, incomingUserID, storedUserID) { continue } - if _, exists := byID[account.ID]; !exists { - byID[account.ID] = DataImportIdentityMatch{AccountID: account.ID, Name: account.Name, MatchedBy: key.Label} - } + byID[account.ID] = DataImportIdentityMatch{AccountID: account.ID} } } out := make([]DataImportIdentityMatch, 0, len(byID)) diff --git a/backend/internal/handler/admin/account_data_import_decision.go b/backend/internal/handler/admin/account_data_import_decision.go index e446bf3f613a..bd9d4344263a 100644 --- a/backend/internal/handler/admin/account_data_import_decision.go +++ b/backend/internal/handler/admin/account_data_import_decision.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -89,7 +88,6 @@ func (h *AccountHandler) previewDataImport(ctx context.Context, req DataImportRe identityIndex = buildDataIdentityIndex(existing) } - request := extensionv1.AccountImportPlanningRequest{Items: make([]extensionv1.AccountImportItemFacts, len(req.Data.Accounts))} decisions := make([]dataImportDecision, len(req.Data.Accounts)) for index := range req.Data.Accounts { item := req.Data.Accounts[index] @@ -99,28 +97,23 @@ func (h *AccountHandler) previewDataImport(ctx context.Context, req DataImportRe if validationErr != nil { decision.ValidationError = validationErr.Error() } - facts := extensionv1.AccountImportItemFacts{PayloadValid: validationErr == nil} - keys := dataAccountIdentityKeys(item.Platform, item.Credentials, item.Extra) - for _, match := range identityIndex.Find(keys) { + matches := identityIndex.Find(dataAccountIdentityKeys(item.Platform, item.Credentials, item.Extra)) + for _, match := range matches { decision.MatchedAccountIDs = append(decision.MatchedAccountIDs, match.AccountID) - if len(facts.Matches) < 2 { - facts.Matches = append(facts.Matches, match.AccountID) - } // Two witnesses already prove ambiguity to the policy. } - request.Items[index], decisions[index] = facts, decision - } - plans, err := service.PlanAccountImport(ctx, request) - if err != nil { - return preview, nil, err - } - for index, plan := range plans { - decision := &decisions[index] - decision.Action, decision.Code = plan.Action, plan.Code - decision.Message = dataImportDecisionMessage(*decision) - if plan.AccountID > 0 { - id := plan.AccountID - decision.AccountID = &id + switch { + case validationErr != nil: + decision.Action, decision.Code = dataImportActionReject, dataImportCodePayloadInvalid + case len(matches) == 0: + decision.Action, decision.Code = dataImportActionCreate, dataImportCodeCreate + case len(matches) == 1: + accountID := matches[0].AccountID + decision.Action, decision.Code, decision.AccountID = dataImportActionUpdate, dataImportCodeUpdate, &accountID + default: + decision.Action, decision.Code = dataImportActionReject, dataImportCodeIdentityConflict } + decision.Message = dataImportDecisionMessage(decision) + decisions[index] = decision } for index := range decisions { diff --git a/backend/internal/handler/admin/account_data_v2_handler_test.go b/backend/internal/handler/admin/account_data_v2_handler_test.go index f487fc065471..39a83a6d0f96 100644 --- a/backend/internal/handler/admin/account_data_v2_handler_test.go +++ b/backend/internal/handler/admin/account_data_v2_handler_test.go @@ -567,3 +567,50 @@ func TestPreviewDataImportKeepsRejectReasons(t *testing.T) { require.Equal(t, dataImportCodePayloadInvalid, preview.Items[1].Code) require.Equal(t, "tag: name must contain between 1 and 100 characters", preview.Items[1].Message) } + +// No identity match creates the account, exactly one updates it, and an invalid +// entry is rejected even when its identity matches an existing account. +func TestPreviewDataImportDecidesEachEntryByItsIdentityMatches(t *testing.T) { + svc := newDataV2AdminService() + svc.accounts = []service.Account{ + {ID: 21, Name: "Known", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "workspace-2", "chatgpt_user_id": "user-2"}, Status: service.StatusActive}, + } + invalid := testDataAccount("InvalidKnown", "workspace-2", "user-2", "invalid@example.com") + invalid["tags"] = []any{" "} + raw, err := json.Marshal(map[string]any{"data": map[string]any{ + "type": dataType, "version": dataVersion, "proxies": []any{}, + "accounts": []any{ + testDataAccount("Fresh", "workspace-3", "user-3", "fresh@example.com"), + testDataAccount("Known", "workspace-2", "user-2", "known@example.com"), + invalid, + }, + }}) + require.NoError(t, err) + var req DataImportRequest + require.NoError(t, json.Unmarshal(raw, &req)) + + preview, decisions, err := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).previewDataImport(context.Background(), req) + require.NoError(t, err) + require.Len(t, preview.Items, 3) + require.Equal(t, 1, preview.CreateCount) + require.Equal(t, 1, preview.UpdateCount) + require.Equal(t, 1, preview.RejectCount) + + require.Equal(t, dataImportActionCreate, preview.Items[0].Action) + require.Equal(t, dataImportCodeCreate, preview.Items[0].Code) + require.Nil(t, preview.Items[0].AccountID) + require.Equal(t, "account will be created", preview.Items[0].Message) + + require.Equal(t, dataImportActionUpdate, preview.Items[1].Action) + require.Equal(t, dataImportCodeUpdate, preview.Items[1].Code) + require.NotNil(t, preview.Items[1].AccountID) + require.EqualValues(t, 21, *preview.Items[1].AccountID) + require.Equal(t, "account will be updated", preview.Items[1].Message) + + require.Equal(t, dataImportActionReject, preview.Items[2].Action) + require.Equal(t, dataImportCodePayloadInvalid, preview.Items[2].Code) + require.Nil(t, preview.Items[2].AccountID) + require.Equal(t, []int64{21}, preview.Items[2].MatchedAccountIDs) + require.True(t, decisions[2].rejected()) +} diff --git a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go index 1efdb27f2eef..eebe90a656a4 100644 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go @@ -10,7 +10,6 @@ import ( extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/Wei-Shaw/sub2api/internal/testextensions" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -47,7 +46,7 @@ func TestAccountHandlerTrafficTelemetryFollowsTelemetrySwitch(t *testing.T) { gin.SetMode(gin.TestMode) for _, enabled := range []bool{false, true} { t.Run(strconv.FormatBool(enabled), func(t *testing.T) { - t.Cleanup(testextensions.Install) + t.Cleanup(func() { service.ConfigureAdminObservability(nil) }) service.ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) stub := &scopedTrafficAdminService{stubAdminService: newStubAdminService()} stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, Credentials: map[string]any{"api_key": "never-return-secret"}} diff --git a/backend/internal/handler/admin/account_taxonomy.go b/backend/internal/handler/admin/account_taxonomy.go index 743a69958e9b..40ec341fd067 100644 --- a/backend/internal/handler/admin/account_taxonomy.go +++ b/backend/internal/handler/admin/account_taxonomy.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -382,7 +381,7 @@ func (h *AccountHandler) BulkUpdateAccountTaxonomy(c *gin.Context) { return } - if err := service.ValidateAccountTaxonomyPlan(c.Request.Context(), extensionv1.TaxonomyBulkPlan{AccountIDs: targetIDs, HasFilters: false, ExpectedMatchCount: req.ExpectedMatchCount, FolderAction: req.FolderAction, FolderID: req.FolderID, TagAddIDs: req.TagAddIDs, TagRemoveIDs: req.TagRemoveIDs}); err != nil { + if err := service.ValidateBulkAccountTaxonomy(service.BulkAccountTaxonomyInput{AccountIDs: targetIDs, FolderAction: req.FolderAction, FolderID: req.FolderID, TagAddIDs: req.TagAddIDs, TagRemoveIDs: req.TagRemoveIDs}); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index 7e45bafaaee7..e3828dcb5834 100644 --- a/backend/internal/handler/admin/account_test_plan.go +++ b/backend/internal/handler/admin/account_test_plan.go @@ -24,7 +24,6 @@ type accountTestPlanView struct { DefaultMode string `json:"default_mode"` Models []map[string]any `json:"models"` ModeViews map[string]accountTestModeView `json:"mode_views"` - PolicyStamp string `json:"policy_stamp,omitempty"` } func accountTestPlanRequested(view string) (bool, error) { @@ -42,8 +41,6 @@ func (h *AccountHandler) accountTestPlan(ctx context.Context, account *service.A if account == nil || account.ID <= 0 { return nil, errors.New("account test target is unavailable") } - // Official providers do not acquire an account-tools dependency merely to - // display a model list or perform a basic connection test. raw, err := h.accountTestModels(ctx, account) if err != nil { return nil, err @@ -116,8 +113,7 @@ func accountTestPlanModels(raw any) ([]map[string]any, error) { func basicAccountTestPlan(account *service.Account, models []map[string]any, defaultID string) *accountTestPlanView { ids := make([]string, 0, len(models)) for _, model := range models { - // Both callers validate these freshly decoded maps with accountTestPlanModels. - id, _ := model["id"].(string) + id, _ := model["id"].(string) // Validated by accountTestPlanModels. ids = append(ids, id) } view := accountTestModeView{ModelIDs: ids, DefaultModelID: defaultID} @@ -140,7 +136,7 @@ func connectionTestDefault(account *service.Account, ids []string) string { } // These choices originate in the official single-account test UI. Keep their -// current behavior in one core view producer, not in two frontend bundles. +// current behavior in this one view producer. func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPlanView, error) { models, err := accountTestPlanModels(raw) if err != nil { diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index a8615c32ee33..5406b5297e1a 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -7,16 +7,12 @@ import ( "net/http/httptest" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/Wei-Shaw/sub2api/internal/testextensions" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) -type testPlanOuterKey struct{} - type testPlanAdmin struct { service.AdminService account *service.Account @@ -29,73 +25,41 @@ func (s testPlanAdmin) GetAccountsByIDs(context.Context, []int64) ([]*service.Ac return []*service.Account{s.account}, nil } -type testPlanOperations struct { - rejectAll bool - calls []extensionv1.Invocation - contexts []any -} - -func (f *testPlanOperations) InvokeOperation(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - f.calls = append(f.calls, in) - f.contexts = append(f.contexts, ctx.Value(testPlanOuterKey{})) - if f.rejectAll { - return extensionv1.Result{}, service.ErrExtensionOperationDisabled - } - return extensionv1.Result{}, service.ErrExtensionOperationDisabled -} - -func TestAccountTestPlanOrdinaryAndLegacyKeepCoreWithoutPlugins(t *testing.T) { +func TestAccountTestPlanViewListsTheSameModelsAsThePlainList(t *testing.T) { gin.SetMode(gin.TestMode) - for _, tc := range []struct { - name string - baseURL string - }{ - {"ordinary", "https://api.openai.com"}, - } { - t.Run(tc.name, func(t *testing.T) { - fixture := &testPlanOperations{rejectAll: true} - service.ConfigureNativePolicyOperations(fixture) - t.Cleanup(testextensions.Install) - account := &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"base_url": tc.baseURL, "model_mapping": map[string]any{"plain-id": "plain-wire"}}} - // Only local mapping metadata is available: no test/discovery client, - // model IO route, or live extension is installed in this fixture. - h := &AccountHandler{adminService: testPlanAdmin{account: account}} - require.Nil(t, h.accountTestService) - router := gin.New() - router.GET("/accounts/:id/models", h.GetAvailableModels) - read := func(suffix string) json.RawMessage { - out := httptest.NewRecorder() - router.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/accounts/42/models"+suffix, nil)) - require.Equal(t, http.StatusOK, out.Code, out.Body.String()) - var body struct { - Data json.RawMessage `json:"data"` - } - require.NoError(t, json.Unmarshal(out.Body.Bytes(), &body)) - return body.Data - } - oldData := read("") - require.True(t, len(oldData) > 0 && oldData[0] == '[', "old GET must remain an array") - var oldModels []map[string]any - require.NoError(t, json.Unmarshal(oldData, &oldModels)) - require.Len(t, oldModels, 1) - require.Equal(t, "plain-id", oldModels[0]["id"]) - oldCalls := fixture.calls - fixture.calls = nil - - var plan accountTestPlanView - require.NoError(t, json.Unmarshal(read("?view=account-test-plan-v1"), &plan)) - require.Equal(t, 1, plan.SchemaVersion) - require.Equal(t, int64(42), plan.AccountID) - require.Equal(t, "openai", plan.WirePlatform) - require.Equal(t, oldModels, plan.Models) - require.Equal(t, []string{"plain-id"}, plan.ModeViews["default"].ModelIDs) - require.Equal(t, "plain-id", plan.ModeViews["default"].DefaultModelID) - require.Empty(t, plan.PolicyStamp) - require.Equal(t, oldCalls, fixture.calls, "the view must not add calls or change optional metadata queries relative to the same legacy GET") - require.Empty(t, fixture.calls, "ordinary OpenAI has no account-tools dependency") - }) + account := &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.openai.com", "model_mapping": map[string]any{"plain-id": "plain-wire"}}} + // Only local mapping metadata is available: no test or discovery client is + // installed in this fixture. + h := &AccountHandler{adminService: testPlanAdmin{account: account}} + require.Nil(t, h.accountTestService) + router := gin.New() + router.GET("/accounts/:id/models", h.GetAvailableModels) + read := func(suffix string) json.RawMessage { + out := httptest.NewRecorder() + router.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/accounts/42/models"+suffix, nil)) + require.Equal(t, http.StatusOK, out.Code, out.Body.String()) + var body struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Body.Bytes(), &body)) + return body.Data } + plainData := read("") + require.True(t, len(plainData) > 0 && plainData[0] == '[', "the plain GET must remain an array") + var plainModels []map[string]any + require.NoError(t, json.Unmarshal(plainData, &plainModels)) + require.Len(t, plainModels, 1) + require.Equal(t, "plain-id", plainModels[0]["id"]) + + var plan accountTestPlanView + require.NoError(t, json.Unmarshal(read("?view=account-test-plan-v1"), &plan)) + require.Equal(t, 1, plan.SchemaVersion) + require.Equal(t, int64(42), plan.AccountID) + require.Equal(t, "openai", plan.WirePlatform) + require.Equal(t, plainModels, plan.Models) + require.Equal(t, []string{"plain-id"}, plan.ModeViews["default"].ModelIDs) + require.Equal(t, "plain-id", plan.ModeViews["default"].DefaultModelID) } func TestAccountTestPlanCoreSelectionSmallMatrix(t *testing.T) { diff --git a/backend/internal/handler/admin/native_policy_fixture_test.go b/backend/internal/handler/admin/native_policy_fixture_test.go deleted file mode 100644 index c7085c522359..000000000000 --- a/backend/internal/handler/admin/native_policy_fixture_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package admin - -import "github.com/Wei-Shaw/sub2api/internal/testextensions" - -func init() { testextensions.Install() } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 77cf4db7dc2a..35918b5702ba 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -109,32 +109,6 @@ func (h *SettingHandler) GetOfficialModelContextCatalog(c *gin.Context) { }) } -// GetImageToolsSettings returns the Image Studio switch this process applies. -// GET /api/v1/admin/settings/image-tools -func (h *SettingHandler) GetImageToolsSettings(c *gin.Context) { - response.Success(c, service.EffectiveImageToolsConfig()) -} - -// UpdateImageToolsSettings saves the Image Studio switch. An omitted switch is -// off, as in the former plugin configuration. -// PUT /api/v1/admin/settings/image-tools -func (h *SettingHandler) UpdateImageToolsSettings(c *gin.Context) { - var req extensionv1.ImageToolsConfig - raw, err := c.GetRawData() - if err == nil { - err = service.DecodeSwitchSettings(raw, &req, "studio_enabled") - } - if err != nil { - response.BadRequest(c, "Invalid image tools settings: "+err.Error()) - return - } - if err := h.settingService.UpdateImageToolsConfig(c.Request.Context(), req); err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, req) -} - // GetObservabilitySettings returns the account traffic telemetry and flat theme // switches this process applies. // GET /api/v1/admin/settings/observability diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 634e45c5c922..97a6c5f2d8ed 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -442,7 +442,6 @@ type PublicSettings struct { ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ImageStudioEnabled bool `json:"image_studio_enabled"` FlatThemeEnabled bool `json:"flat_theme_enabled"` SubscriptionEnabled bool `json:"subscription_enabled"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 74830149291d..947b96a5da6f 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -71,7 +71,6 @@ type Handlers struct { ModelPlaza *ModelPlazaHandler AsyncImage *AsyncImageHandler BatchImage *BatchImageHandler - ImageStudio *ImageStudioJobHandler } // BuildInfo contains build-time information diff --git a/backend/internal/handler/image_studio_executor.go b/backend/internal/handler/image_studio_executor.go deleted file mode 100644 index a0a9b1079acc..000000000000 --- a/backend/internal/handler/image_studio_executor.go +++ /dev/null @@ -1,275 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/textproto" - "strconv" - "strings" - "sync" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -type imageStudioImagesInvoker interface { - Images(*gin.Context) -} - -type imageStudioSubscriptionFinder interface { - GetActiveSubscription(context.Context, int64, int64) (*service.UserSubscription, error) -} - -type ImageStudioGatewayExecutor struct { - images imageStudioImagesInvoker - subscriptions imageStudioSubscriptionFinder - ops *service.OpsService - - routerOnce sync.Once - router *gin.Engine -} - -func NewImageStudioGatewayExecutor(images imageStudioImagesInvoker, subscriptions *service.SubscriptionService, ops *service.OpsService) *ImageStudioGatewayExecutor { - return &ImageStudioGatewayExecutor{images: images, subscriptions: subscriptions, ops: ops} -} - -func newImageStudioGatewayExecutorForTest(images imageStudioImagesInvoker, subscriptions imageStudioSubscriptionFinder) *ImageStudioGatewayExecutor { - return &ImageStudioGatewayExecutor{images: images, subscriptions: subscriptions} -} - -// imageStudioGatewayCall carries one job item's authentication into the shared -// in-process router. -type imageStudioGatewayCall struct { - apiKey *service.APIKey - subscription *service.UserSubscription -} - -type imageStudioGatewayCallKey struct{} - -// gatewayRouter runs Image Studio requests through the Ops error logger in -// front of the images handler, as the public /v1/images routes do, so failed -// generations leave an Ops error record. -func (e *ImageStudioGatewayExecutor) gatewayRouter() *gin.Engine { - e.routerOnce.Do(func() { - router := gin.New() - router.Any("/*endpoint", OpsErrorLoggerMiddleware(e.ops), imageStudioGatewayAuth, e.images.Images) - e.router = router - }) - return e.router -} - -// applyImageStudioUsageOrigin records the Image Studio submitter's address and -// User-Agent on the usage row of an in-process gateway request. -func applyImageStudioUsageOrigin(c *gin.Context, snapshot *openAIUsageSnapshot) { - if c == nil || c.Request == nil || snapshot == nil { - return - } - origin, ok := service.ImageStudioRequestOriginFromContext(c.Request.Context()) - if !ok { - return - } - if userAgent := strings.TrimSpace(origin.UserAgent); userAgent != "" { - snapshot.userAgent = userAgent - } - if clientIP := strings.TrimSpace(origin.ClientIP); clientIP != "" { - snapshot.ipAddress = clientIP - } -} - -// applyImageStudioOpsOrigin records the Image Studio submitter's address and -// User-Agent on the Ops row of an in-process gateway request. -func applyImageStudioOpsOrigin(c *gin.Context, entry *service.OpsInsertErrorLogInput) { - if c == nil || c.Request == nil || entry == nil { - return - } - origin, ok := service.ImageStudioRequestOriginFromContext(c.Request.Context()) - if !ok { - return - } - if userAgent := strings.TrimSpace(origin.UserAgent); userAgent != "" { - entry.UserAgent = userAgent - } - if clientIP := strings.TrimSpace(origin.ClientIP); clientIP != "" { - entry.ClientIP = &clientIP - } -} - -func imageStudioGatewayAuth(c *gin.Context) { - call, _ := c.Request.Context().Value(imageStudioGatewayCallKey{}).(*imageStudioGatewayCall) - if call == nil || call.apiKey == nil || call.apiKey.User == nil { - c.AbortWithStatus(http.StatusServiceUnavailable) - return - } - c.Set(string(middleware2.ContextKeyAPIKey), call.apiKey) - c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{ - UserID: call.apiKey.UserID, Concurrency: call.apiKey.User.Concurrency, - }) - c.Set(string(middleware2.ContextKeyUserRole), call.apiKey.User.Role) - if call.subscription != nil { - c.Set(string(middleware2.ContextKeySubscription), call.subscription) - } - c.Next() -} - -func (e *ImageStudioGatewayExecutor) Execute(ctx context.Context, request service.ImageStudioExecutionRequest) (*service.ImageStudioExecutionResult, error) { - if e == nil || e.images == nil || request.APIKey == nil || request.APIKey.User == nil { - return nil, errors.New("image gateway is unavailable") - } - plan, err := service.PlanImageStudioExecution(ctx, request) - if err != nil { - return nil, err - } - body, contentType, path, err := buildImageStudioGatewayRequest(request, plan) - if err != nil { - return nil, err - } - call := &imageStudioGatewayCall{apiKey: request.APIKey} - if request.APIKey.Group != nil && request.APIKey.Group.IsSubscriptionType() { - if e.subscriptions == nil { - return nil, errors.New("image subscription is unavailable") - } - subscription, lookupErr := e.subscriptions.GetActiveSubscription(ctx, request.APIKey.UserID, request.APIKey.Group.ID) - if lookupErr != nil { - return nil, fmt.Errorf("image subscription is unavailable: %w", lookupErr) - } - if subscription == nil { - return nil, errors.New("image subscription is unavailable") - } - call.subscription = subscription - } - // The correlation ID ties Ops and usage rows to this attempt of the job - // item; it is also the usage billing dedupe key, so it is new per attempt. - ctx = context.WithValue(ctx, ctxkey.ClientRequestID, service.NewImageStudioClientRequestID(request.Job.ID, request.Item.ID)) - ctx = context.WithValue(ctx, imageStudioGatewayCallKey{}, call) - // The job's submitter is recorded on usage and Ops rows from the context. - // It is not put on the request: the images handler forwards request - // headers such as User-Agent to API-key upstreams. - ctx = service.WithImageStudioRequestOrigin(ctx, request.Origin) - httpRequest := httptest.NewRequest(http.MethodPost, path, body).WithContext(ctx) - httpRequest.Header.Set("Content-Type", contentType) - recorder := httptest.NewRecorder() - e.gatewayRouter().ServeHTTP(recorder, httpRequest) - if recorder.Code != http.StatusOK { - return nil, &service.ImageStudioGatewayError{StatusCode: recorder.Code, Body: recorder.Body.String()} - } - return decodeImageStudioGatewayResponse(recorder.Body.Bytes()) -} - -func buildImageStudioGatewayRequest(request service.ImageStudioExecutionRequest, plan extensionv1.ImageStudioPlan) (*bytes.Reader, string, string, error) { - if plan.Mode == string(service.ImageStudioModeEdit) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - fields := map[string]string{ - "model": plan.Model, "prompt": request.Job.Prompt, "n": strconv.Itoa(plan.OutputPerRequest), - "response_format": plan.ResponseFormat, "size": plan.Size, "quality": plan.Quality, - } - for name, value := range fields { - if value != "" { - if err := writer.WriteField(name, value); err != nil { - return nil, "", "", errors.New("build image edit request") - } - } - } - if err := writeImageStudioMultipartFile(writer, "image", "reference", request.ReferenceContentType, request.Reference); err != nil { - return nil, "", "", err - } - if len(request.Mask) > 0 { - if err := writeImageStudioMultipartFile(writer, "mask", "mask", request.MaskContentType, request.Mask); err != nil { - return nil, "", "", err - } - } - if err := writer.Close(); err != nil { - return nil, "", "", errors.New("build image edit request") - } - return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), plan.Endpoint, nil - } - payload := map[string]any{ - "model": plan.Model, "prompt": request.Job.Prompt, "n": plan.OutputPerRequest, - "response_format": plan.ResponseFormat, "size": plan.Size, "quality": plan.Quality, - } - body, err := json.Marshal(payload) - if err != nil { - return nil, "", "", errors.New("build image generation request") - } - return bytes.NewReader(body), "application/json", plan.Endpoint, nil -} - -func writeImageStudioMultipartFile(writer *multipart.Writer, field, baseName, contentType string, data []byte) error { - if len(data) == 0 { - return errors.New("image input is unavailable") - } - extension := ".png" - switch strings.ToLower(strings.TrimSpace(contentType)) { - case "image/jpeg": - extension = ".jpg" - case "image/webp": - extension = ".webp" - } - header := make(textproto.MIMEHeader) - header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, baseName+extension)) - header.Set("Content-Type", contentType) - part, err := writer.CreatePart(header) - if err != nil { - return errors.New("build image edit request") - } - if _, err = part.Write(data); err != nil { - return errors.New("build image edit request") - } - return nil -} - -func decodeImageStudioGatewayResponse(body []byte) (*service.ImageStudioExecutionResult, error) { - var payload struct { - Data []struct { - B64JSON string `json:"b64_json"` - RevisedPrompt string `json:"revised_prompt"` - } `json:"data"` - } - if err := json.Unmarshal(body, &payload); err != nil { - return nil, fmt.Errorf("image gateway returned a result that is not JSON: %w; response: %s", err, service.ImageStudioLogText(string(body))) - } - if len(payload.Data) != 1 || payload.Data[0].B64JSON == "" { - return nil, fmt.Errorf("image gateway returned %d images instead of one base64 image; response: %s", len(payload.Data), service.ImageStudioLogText(string(body))) - } - encoded := payload.Data[0].B64JSON - if size := base64.StdEncoding.DecodedLen(len(encoded)); size > service.ImageStudioMaxImageBytes { - return nil, fmt.Errorf("image gateway returned an oversized image: about %d bytes, the limit is %d", size, service.ImageStudioMaxImageBytes) - } - data, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("image gateway returned an image that is not valid base64: %w", err) - } - if len(data) > service.ImageStudioMaxImageBytes { - return nil, fmt.Errorf("image gateway returned an oversized image: %d bytes, the limit is %d", len(data), service.ImageStudioMaxImageBytes) - } - contentType := imageStudioContentType(data) - if contentType == "" { - return nil, fmt.Errorf("image gateway returned %d bytes that are not a PNG, JPEG or WebP image (first bytes %x)", len(data), data[:min(len(data), 12)]) - } - return &service.ImageStudioExecutionResult{ - Data: data, ContentType: contentType, RevisedPrompt: strings.TrimSpace(payload.Data[0].RevisedPrompt), - }, nil -} - -func imageStudioContentType(data []byte) string { - switch { - case len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n": - return "image/png" - case len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff: - return "image/jpeg" - case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP": - return "image/webp" - default: - return "" - } -} diff --git a/backend/internal/handler/image_studio_executor_test.go b/backend/internal/handler/image_studio_executor_test.go deleted file mode 100644 index c63fcdbaed06..000000000000 --- a/backend/internal/handler/image_studio_executor_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package handler - -import ( - "context" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type imageStudioImagesInvokerFunc func(*gin.Context) - -func (f imageStudioImagesInvokerFunc) Images(c *gin.Context) { f(c) } - -func imageStudioExecutorAPIKey() *service.APIKey { - groupID := int64(31) - return &service.APIKey{ - ID: 21, UserID: 11, Key: "server-only-secret", Status: service.StatusActive, - GroupID: &groupID, - Group: &service.Group{ - ID: groupID, Platform: service.PlatformOpenAI, Status: service.StatusActive, AllowImageGeneration: true, - }, - User: &service.User{ID: 11, Status: service.StatusActive, Concurrency: 4}, - } -} - -func imageStudioExecutorPNG() []byte { - return []byte("\x89PNG\r\n\x1a\nfixture") -} - -func TestImageStudioGatewayExecutorUsesNativeNOneForGenerate(t *testing.T) { - enableImageStudioPolicy(t) - gin.SetMode(gin.TestMode) - var got map[string]any - invoker := imageStudioImagesInvokerFunc(func(c *gin.Context) { - require.Equal(t, "/v1/images/generations", c.Request.URL.Path) - require.Contains(t, c.GetHeader("Content-Type"), "application/json") - require.NoError(t, json.NewDecoder(c.Request.Body).Decode(&got)) - c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": base64.StdEncoding.EncodeToString(imageStudioExecutorPNG()), "revised_prompt": "revised"}}}) - }) - executor := newImageStudioGatewayExecutorForTest(invoker, nil) - result, err := executor.Execute(context.Background(), service.ImageStudioExecutionRequest{ - Job: service.ImageStudioJob{UserID: 11, Mode: service.ImageStudioModeGenerate, Model: service.ImageStudioModelGPTImage2, Prompt: "draw", Size: "1024x1024", Quality: "low", Count: 4}, - APIKey: imageStudioExecutorAPIKey(), - }) - require.NoError(t, err) - require.EqualValues(t, 1, got["n"]) - require.Equal(t, service.ImageStudioModelGPTImage2, got["model"]) - require.Equal(t, "b64_json", got["response_format"]) - require.Equal(t, "image/png", result.ContentType) - require.Equal(t, "revised", result.RevisedPrompt) -} - -func TestImageStudioGatewayExecutorUsesNativeNOneForEdit(t *testing.T) { - enableImageStudioPolicy(t) - gin.SetMode(gin.TestMode) - invoker := imageStudioImagesInvokerFunc(func(c *gin.Context) { - require.Equal(t, "/v1/images/edits", c.Request.URL.Path) - require.NoError(t, c.Request.ParseMultipartForm(service.ImageStudioMaxImageBytes*2)) - require.Equal(t, "1", c.Request.FormValue("n")) - require.Equal(t, service.ImageStudioModelGeminiProImage, c.Request.FormValue("model")) - image, _, err := c.Request.FormFile("image") - require.NoError(t, err) - defer func() { require.NoError(t, image.Close()) }() - gotImage, err := io.ReadAll(image) - require.NoError(t, err) - require.Equal(t, imageStudioExecutorPNG(), gotImage) - mask, _, err := c.Request.FormFile("mask") - require.NoError(t, err) - defer func() { require.NoError(t, mask.Close()) }() - gotMask, err := io.ReadAll(mask) - require.NoError(t, err) - require.True(t, strings.HasPrefix(string(gotMask), "\x89PNG")) - c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": base64.StdEncoding.EncodeToString(imageStudioExecutorPNG())}}}) - }) - executor := newImageStudioGatewayExecutorForTest(invoker, nil) - result, err := executor.Execute(context.Background(), service.ImageStudioExecutionRequest{ - Job: service.ImageStudioJob{UserID: 11, Mode: service.ImageStudioModeEdit, Model: service.ImageStudioModelGeminiProImage, Prompt: "edit", Size: "1024x1024", Quality: "low"}, - APIKey: imageStudioExecutorAPIKey(), Reference: imageStudioExecutorPNG(), ReferenceContentType: "image/png", - Mask: imageStudioExecutorPNG(), MaskContentType: "image/png", - }) - require.NoError(t, err) - require.Equal(t, "image/png", result.ContentType) -} - -// A failed generation keeps the gateway status and body for administrators -// (server log and Ops). The submitter's IP and User-Agent reach the usage and -// Ops rows from the context; the request headers, which the images handler -// forwards upstream, do not carry them. -func TestImageStudioGatewayExecutorKeepsGatewayStatusBodyAndOrigin(t *testing.T) { - enableImageStudioPolicy(t) - executor := newImageStudioGatewayExecutorForTest(imageStudioImagesInvokerFunc(func(c *gin.Context) { - require.Empty(t, c.GetHeader("User-Agent")) - require.NotEqual(t, "203.0.113.9", c.ClientIP()) - entry := &service.OpsInsertErrorLogInput{UserAgent: c.GetHeader("User-Agent")} - applyImageStudioOpsOrigin(c, entry) - require.Equal(t, "studio-browser/1.0", entry.UserAgent) - require.NotNil(t, entry.ClientIP) - require.Equal(t, "203.0.113.9", *entry.ClientIP) - snapshot := &openAIUsageSnapshot{} - applyImageStudioUsageOrigin(c, snapshot) - require.Equal(t, "studio-browser/1.0", snapshot.userAgent) - require.Equal(t, "203.0.113.9", snapshot.ipAddress) - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": "upstream said no"}}) - }), nil) - _, err := executor.Execute(context.Background(), service.ImageStudioExecutionRequest{ - Job: service.ImageStudioJob{UserID: 11, Mode: service.ImageStudioModeGenerate, Model: service.ImageStudioModelGPTImage2, Prompt: "draw"}, - APIKey: imageStudioExecutorAPIKey(), - Origin: service.ImageStudioRequestOrigin{ClientIP: "203.0.113.9", UserAgent: "studio-browser/1.0"}, - }) - var gatewayErr *service.ImageStudioGatewayError - require.ErrorAs(t, err, &gatewayErr) - require.Equal(t, http.StatusBadGateway, gatewayErr.StatusCode) - require.JSONEq(t, `{"error":{"message":"upstream said no"}}`, gatewayErr.Body) -} - -type imageStudioSubscriptionFinderStub struct { - subscription *service.UserSubscription -} - -func (s *imageStudioSubscriptionFinderStub) GetActiveSubscription(context.Context, int64, int64) (*service.UserSubscription, error) { - return s.subscription, nil -} - -func TestImageStudioGatewayExecutorPreservesSubscriptionBillingContext(t *testing.T) { - enableImageStudioPolicy(t) - key := imageStudioExecutorAPIKey() - key.Group.SubscriptionType = service.SubscriptionTypeSubscription - subscription := &service.UserSubscription{ID: 71, UserID: key.UserID, GroupID: key.Group.ID} - executor := newImageStudioGatewayExecutorForTest(imageStudioImagesInvokerFunc(func(c *gin.Context) { - got, ok := middleware2.GetSubscriptionFromContext(c) - require.True(t, ok) - require.Equal(t, subscription.ID, got.ID) - c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": base64.StdEncoding.EncodeToString(imageStudioExecutorPNG())}}}) - }), &imageStudioSubscriptionFinderStub{subscription: subscription}) - - _, err := executor.Execute(context.Background(), service.ImageStudioExecutionRequest{ - Job: service.ImageStudioJob{UserID: key.UserID, Mode: service.ImageStudioModeGenerate, Model: service.ImageStudioModelGPTImage2, Prompt: "draw"}, - APIKey: key, - }) - require.NoError(t, err) -} - -// The client request ID is the usage billing dedupe key ("client:"+ID), so two -// executions of the same job item must not share it. -func TestImageStudioGatewayExecutorUsesNewBillingIDPerAttempt(t *testing.T) { - enableImageStudioPolicy(t) - var ids []string - executor := newImageStudioGatewayExecutorForTest(imageStudioImagesInvokerFunc(func(c *gin.Context) { - id, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string) - ids = append(ids, id) - c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": base64.StdEncoding.EncodeToString(imageStudioExecutorPNG())}}}) - }), nil) - request := service.ImageStudioExecutionRequest{ - Job: service.ImageStudioJob{ID: 41, UserID: 11, Mode: service.ImageStudioModeGenerate, Model: service.ImageStudioModelGPTImage2, Prompt: "draw"}, - Item: service.ImageStudioItem{ID: 7}, - APIKey: imageStudioExecutorAPIKey(), - } - for range 2 { - _, err := executor.Execute(context.Background(), request) - require.NoError(t, err) - } - require.Len(t, ids, 2) - require.NotEqual(t, ids[0], ids[1]) - for _, id := range ids { - require.True(t, strings.HasPrefix(id, service.ImageStudioClientRequestIDPrefix+"job-41-item-7-"), id) - require.LessOrEqual(t, len("client:"+id), 64) - } -} - -// Decode failures name the concrete reason; a response that is not an image -// result is quoted only up to 4 KiB. -func TestDecodeImageStudioGatewayResponseReportsConcreteReasons(t *testing.T) { - _, err := decodeImageStudioGatewayResponse([]byte("" + strings.Repeat("x", 64<<10))) - require.ErrorContains(t, err, "not JSON") - require.Less(t, len(err.Error()), 5<<10) - - _, err = decodeImageStudioGatewayResponse([]byte(`{"data":[{"b64_json":"not base64!"}]}`)) - require.ErrorContains(t, err, "not valid base64") - - _, err = decodeImageStudioGatewayResponse([]byte(`{"data":[{"b64_json":"` + base64.StdEncoding.EncodeToString([]byte("GIF89a-fixture")) + `"}]}`)) - require.ErrorContains(t, err, "not a PNG, JPEG or WebP image") -} diff --git a/backend/internal/handler/image_studio_job_handler.go b/backend/internal/handler/image_studio_job_handler.go deleted file mode 100644 index fc20e47e82f4..000000000000 --- a/backend/internal/handler/image_studio_job_handler.go +++ /dev/null @@ -1,324 +0,0 @@ -package handler - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/ip" - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -type imageStudioJobService interface { - EligibleKeys(ctx context.Context, userID int64) ([]service.ImageStudioEligibleKey, error) - Create(ctx context.Context, userID int64, input service.ImageStudioCreateInput, reference, mask *service.ImageStudioUpload) (*service.ImageStudioJob, error) - Get(ctx context.Context, userID, jobID int64) (*service.ImageStudioJob, error) - List(ctx context.Context, userID int64, limit, offset int) ([]service.ImageStudioJob, error) - ListItems(ctx context.Context, userID, jobID int64) ([]service.ImageStudioItem, error) - ListArtifacts(ctx context.Context, userID, jobID int64) ([]service.ImageStudioArtifact, error) - Cancel(ctx context.Context, userID, jobID int64) (*service.ImageStudioJob, error) - Retry(ctx context.Context, userID, jobID int64) (*service.ImageStudioJob, error) - OpenArtifact(ctx context.Context, userID, jobID, artifactID int64) (*service.ImageStudioArtifactDownload, error) -} - -type ImageStudioJobHandler struct { - studio imageStudioJobService -} - -func NewImageStudioJobHandler(studio *service.ImageStudioService) *ImageStudioJobHandler { - return &ImageStudioJobHandler{studio: studio} -} - -func newImageStudioJobHandlerForTest(studio imageStudioJobService) *ImageStudioJobHandler { - return &ImageStudioJobHandler{studio: studio} -} - -func (h *ImageStudioJobHandler) EligibleKeys(c *gin.Context) { - userID, ok := h.authorize(c) - if !ok { - return - } - if err := service.EnsureImageStudioAvailable(c.Request.Context()); err != nil { - imageStudioJobError(c, err) - return - } - items, err := h.studio.EligibleKeys(c.Request.Context(), userID) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Success(c, gin.H{"items": items}) -} - -func (h *ImageStudioJobHandler) Create(c *gin.Context) { - userID, ok := h.authorize(c) - if !ok { - return - } - if err := service.EnsureImageStudioAvailable(c.Request.Context()); err != nil { - imageStudioJobError(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2*service.ImageStudioMaxImageBytes+(1<<20)) - if err := c.Request.ParseMultipartForm(2 * service.ImageStudioMaxImageBytes); err != nil { - imageStudioJobError(c, &service.ImageStudioError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "Image Studio request is invalid"}) - return - } - apiKeyID, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("api_key_id")), 10, 64) - if err != nil { - apiKeyID = 0 - } - count, err := strconv.Atoi(strings.TrimSpace(c.PostForm("count"))) - if err != nil { - count = 0 - } - reference, err := imageStudioUpload(c, "reference") - if err != nil { - imageStudioJobError(c, err) - return - } - mask, err := imageStudioUpload(c, "mask") - if err != nil { - imageStudioJobError(c, err) - return - } - job, err := h.studio.Create(imageStudioRequestContext(c), userID, service.ImageStudioCreateInput{ - APIKeyID: apiKeyID, - Mode: service.ImageStudioMode(c.PostForm("mode")), - Model: c.PostForm("model"), - Prompt: c.PostForm("prompt"), - Count: count, - Size: c.PostForm("size"), - Quality: c.PostForm("quality"), - }, reference, mask) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Accepted(c, job) -} - -func (h *ImageStudioJobHandler) List(c *gin.Context) { - userID, ok := h.authorize(c) - if !ok { - return - } - limit := parseImageStudioPositiveInt(c.Query("limit"), 20) - if limit > 100 { - limit = 100 - } - offset := parseImageStudioPositiveInt(c.Query("offset"), 0) - jobs, err := h.studio.List(c.Request.Context(), userID, limit, offset) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Success(c, gin.H{"items": jobs}) -} - -func (h *ImageStudioJobHandler) Get(c *gin.Context) { - userID, jobID, ok := h.ownerAndJobID(c) - if !ok { - return - } - job, err := h.studio.Get(c.Request.Context(), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - items, err := h.studio.ListItems(c.Request.Context(), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - artifacts, err := h.studio.ListArtifacts(c.Request.Context(), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Success(c, gin.H{"job": job, "items": items, "artifacts": imageStudioArtifactViews(jobID, artifacts)}) -} - -func (h *ImageStudioJobHandler) Items(c *gin.Context) { - userID, jobID, ok := h.ownerAndJobID(c) - if !ok { - return - } - items, err := h.studio.ListItems(c.Request.Context(), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Success(c, gin.H{"items": items}) -} - -func (h *ImageStudioJobHandler) Cancel(c *gin.Context) { - userID, jobID, ok := h.ownerAndJobID(c) - if !ok { - return - } - job, err := h.studio.Cancel(c.Request.Context(), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Success(c, job) -} - -func (h *ImageStudioJobHandler) Retry(c *gin.Context) { - userID, jobID, ok := h.ownerAndJobID(c) - if !ok { - return - } - if err := service.EnsureImageStudioAvailable(c.Request.Context()); err != nil { - imageStudioJobError(c, err) - return - } - job, err := h.studio.Retry(imageStudioRequestContext(c), userID, jobID) - if err != nil { - imageStudioJobError(c, err) - return - } - response.Accepted(c, job) -} - -// imageStudioRequestContext records the submitting client so the job's -// gateway requests carry its real IP address and User-Agent. -func imageStudioRequestContext(c *gin.Context) context.Context { - return service.WithImageStudioRequestOrigin(c.Request.Context(), service.ImageStudioRequestOrigin{ - ClientIP: ip.GetClientIP(c), UserAgent: c.GetHeader("User-Agent"), - }) -} - -func (h *ImageStudioJobHandler) Artifact(c *gin.Context) { - userID, jobID, ok := h.ownerAndJobID(c) - if !ok { - return - } - artifactID, err := strconv.ParseInt(c.Param("artifact_id"), 10, 64) - if err != nil || artifactID <= 0 { - response.NotFound(c, "Image Studio artifact was not found") - return - } - download, err := h.studio.OpenArtifact(c.Request.Context(), userID, jobID, artifactID) - if err != nil { - imageStudioJobError(c, err) - return - } - defer func() { _ = download.Reader.Close() }() - c.Header("Cache-Control", "private, no-store") - c.Header("Content-Type", download.Artifact.ContentType) - c.Header("Content-Length", strconv.FormatInt(download.Artifact.ByteSize, 10)) - c.Header("X-Content-Type-Options", "nosniff") - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="image-studio-%d%s"`, artifactID, imageStudioFileExtension(download.Artifact.ContentType))) - c.Status(http.StatusOK) - _, _ = io.Copy(c.Writer, download.Reader) -} - -func (h *ImageStudioJobHandler) authorize(c *gin.Context) (int64, bool) { - subject, ok := middleware2.GetAuthSubjectFromContext(c) - if !ok || subject.UserID <= 0 { - response.Unauthorized(c, "User not authenticated") - return 0, false - } - if h == nil || h.studio == nil { - response.Error(c, http.StatusServiceUnavailable, "Image Studio is unavailable") - return 0, false - } - return subject.UserID, true -} - -func (h *ImageStudioJobHandler) ownerAndJobID(c *gin.Context) (int64, int64, bool) { - userID, ok := h.authorize(c) - if !ok { - return 0, 0, false - } - jobID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || jobID <= 0 { - response.NotFound(c, "Image Studio job was not found") - return 0, 0, false - } - return userID, jobID, true -} - -func imageStudioUpload(c *gin.Context, field string) (*service.ImageStudioUpload, error) { - header, err := c.FormFile(field) - if errors.Is(err, http.ErrMissingFile) { - return nil, nil - } - if err != nil || header == nil || header.Size <= 0 || header.Size > service.ImageStudioMaxImageBytes { - return nil, &service.ImageStudioError{Status: http.StatusBadRequest, Code: "invalid_image", Message: "Image is empty or exceeds the 20 MB limit"} - } - file, err := header.Open() - if err != nil { - return nil, &service.ImageStudioError{Status: http.StatusBadRequest, Code: "invalid_image", Message: "Image is unavailable"} - } - defer func() { _ = file.Close() }() - data, err := io.ReadAll(io.LimitReader(file, service.ImageStudioMaxImageBytes+1)) - if err != nil || len(data) == 0 || len(data) > service.ImageStudioMaxImageBytes { - return nil, &service.ImageStudioError{Status: http.StatusBadRequest, Code: "invalid_image", Message: "Image is empty or exceeds the 20 MB limit"} - } - return &service.ImageStudioUpload{Data: data, ContentType: header.Header.Get("Content-Type")}, nil -} - -func parseImageStudioPositiveInt(raw string, fallback int) int { - value, err := strconv.Atoi(raw) - if err != nil || value < 0 { - return fallback - } - return value -} - -type imageStudioArtifactView struct { - service.ImageStudioArtifact - DownloadURL string `json:"download_url"` -} - -func imageStudioArtifactViews(jobID int64, artifacts []service.ImageStudioArtifact) []imageStudioArtifactView { - result := make([]imageStudioArtifactView, 0, len(artifacts)) - for i := range artifacts { - result = append(result, imageStudioArtifactView{ - ImageStudioArtifact: artifacts[i], - DownloadURL: fmt.Sprintf("/api/v1/image-studio/jobs/%d/artifacts/%d", jobID, artifacts[i].ID), - }) - } - return result -} - -func imageStudioFileExtension(contentType string) string { - switch contentType { - case "image/jpeg": - return ".jpg" - case "image/webp": - return ".webp" - default: - return ".png" - } -} - -func imageStudioJobError(c *gin.Context, err error) { - var studioErr *service.ImageStudioError - if errors.As(err, &studioErr) && studioErr != nil { - response.ErrorWithDetails(c, studioErr.Status, studioErr.Message, studioErr.Code, nil) - return - } - switch { - case errors.Is(err, service.ErrImageStudioNotFound): - response.NotFound(c, "Image Studio job was not found") - case errors.Is(err, service.ErrImageStudioActiveJob): - response.ErrorWithDetails(c, http.StatusConflict, "An Image Studio job is already active", "active_job_exists", nil) - case errors.Is(err, service.ErrImageStudioNotRetryable): - response.ErrorWithDetails(c, http.StatusConflict, "Image Studio job cannot be retried", "job_not_retryable", nil) - case errors.Is(err, service.ErrImageStudioRequestExpired): - response.ErrorWithDetails(c, http.StatusConflict, "Image Studio input has expired", "input_expired", nil) - default: - response.InternalError(c, "Image Studio request failed") - } -} diff --git a/backend/internal/handler/image_studio_job_handler_test.go b/backend/internal/handler/image_studio_job_handler_test.go deleted file mode 100644 index 7c9044c8fd3c..000000000000 --- a/backend/internal/handler/image_studio_job_handler_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "io" - "mime/multipart" - "net/http" - "net/http/httptest" - "testing" - - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type imageStudioHandlerServiceStub struct { - eligible []service.ImageStudioEligibleKey - created *service.ImageStudioJob - create service.ImageStudioCreateInput - reference *service.ImageStudioUpload - mask *service.ImageStudioUpload - userID int64 - download *service.ImageStudioArtifactDownload -} - -func (s *imageStudioHandlerServiceStub) EligibleKeys(context.Context, int64) ([]service.ImageStudioEligibleKey, error) { - return s.eligible, nil -} -func (s *imageStudioHandlerServiceStub) Create(_ context.Context, userID int64, input service.ImageStudioCreateInput, reference, mask *service.ImageStudioUpload) (*service.ImageStudioJob, error) { - s.userID, s.create, s.reference, s.mask = userID, input, reference, mask - return s.created, nil -} -func (s *imageStudioHandlerServiceStub) Get(context.Context, int64, int64) (*service.ImageStudioJob, error) { - return &service.ImageStudioJob{ID: 41}, nil -} -func (s *imageStudioHandlerServiceStub) List(context.Context, int64, int, int) ([]service.ImageStudioJob, error) { - return nil, nil -} -func (s *imageStudioHandlerServiceStub) ListItems(context.Context, int64, int64) ([]service.ImageStudioItem, error) { - return nil, nil -} -func (s *imageStudioHandlerServiceStub) ListArtifacts(context.Context, int64, int64) ([]service.ImageStudioArtifact, error) { - return nil, nil -} -func (s *imageStudioHandlerServiceStub) Cancel(context.Context, int64, int64) (*service.ImageStudioJob, error) { - return &service.ImageStudioJob{ID: 41, Status: service.ImageStudioJobCanceled}, nil -} -func (s *imageStudioHandlerServiceStub) Retry(context.Context, int64, int64) (*service.ImageStudioJob, error) { - return &service.ImageStudioJob{ID: 41, Status: service.ImageStudioJobPending}, nil -} -func (s *imageStudioHandlerServiceStub) OpenArtifact(context.Context, int64, int64, int64) (*service.ImageStudioArtifactDownload, error) { - return s.download, nil -} - -func imageStudioHandlerContext(method, target string, body io.Reader, contentType string) (*gin.Context, *httptest.ResponseRecorder) { - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(method, target, body) - if contentType != "" { - c.Request.Header.Set("Content-Type", contentType) - } - c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7}) - return c, recorder -} - -func TestImageStudioJobHandlerEligibleKeysNeverReturnsSecret(t *testing.T) { - enableImageStudioPolicy(t) - stub := &imageStudioHandlerServiceStub{eligible: []service.ImageStudioEligibleKey{{ - APIKey: service.ImageStudioEligibleAPIKey{ID: 9, Name: "studio", GroupID: 10}, - }}} - c, recorder := imageStudioHandlerContext(http.MethodGet, "/api/v1/image-studio/eligible-keys", nil, "") - newImageStudioJobHandlerForTest(stub).EligibleKeys(c) - require.Equal(t, http.StatusOK, recorder.Code) - require.NotContains(t, recorder.Body.String(), "server-only-secret") - require.NotContains(t, recorder.Body.String(), `"key"`) -} - -func TestImageStudioJobHandlerCreateReturnsAcceptedAndUsesSessionOwner(t *testing.T) { - enableImageStudioPolicy(t) - var body bytes.Buffer - writer := multipart.NewWriter(&body) - require.NoError(t, writer.WriteField("api_key_id", "9")) - require.NoError(t, writer.WriteField("mode", "edit")) - require.NoError(t, writer.WriteField("model", service.ImageStudioModelGeminiProImage)) - require.NoError(t, writer.WriteField("prompt", "replace sky")) - require.NoError(t, writer.WriteField("count", "4")) - part, err := writer.CreateFormFile("reference", "reference.png") - require.NoError(t, err) - _, err = part.Write(imageStudioExecutorPNG()) - require.NoError(t, err) - require.NoError(t, writer.Close()) - stub := &imageStudioHandlerServiceStub{created: &service.ImageStudioJob{ID: 41, Status: service.ImageStudioJobPending}} - c, recorder := imageStudioHandlerContext(http.MethodPost, "/api/v1/image-studio/jobs", &body, writer.FormDataContentType()) - newImageStudioJobHandlerForTest(stub).Create(c) - require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) - require.Equal(t, int64(7), stub.userID) - require.Equal(t, int64(9), stub.create.APIKeyID) - require.Equal(t, 4, stub.create.Count) - require.NotNil(t, stub.reference) - require.Nil(t, stub.mask) -} - -func TestImageStudioJobHandlerArtifactUsesSessionOwner(t *testing.T) { - enableImageStudioPolicy(t) - stub := &imageStudioHandlerServiceStub{download: &service.ImageStudioArtifactDownload{ - Artifact: &service.ImageStudioArtifact{ID: 52, ContentType: "image/png", ByteSize: int64(len(imageStudioExecutorPNG()))}, - Reader: io.NopCloser(bytes.NewReader(imageStudioExecutorPNG())), - }} - c, recorder := imageStudioHandlerContext(http.MethodGet, "/api/v1/image-studio/jobs/41/artifacts/52", nil, "") - c.Params = gin.Params{{Key: "id", Value: "41"}, {Key: "artifact_id", Value: "52"}} - newImageStudioJobHandlerForTest(stub).Artifact(c) - require.Equal(t, http.StatusOK, recorder.Code) - require.Equal(t, "private, no-store", recorder.Header().Get("Cache-Control")) - require.Equal(t, imageStudioExecutorPNG(), recorder.Body.Bytes()) -} - -func TestImageStudioJobHandlerRequiresSessionOwner(t *testing.T) { - enableImageStudioPolicy(t) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/image-studio/eligible-keys", nil) - newImageStudioJobHandlerForTest(&imageStudioHandlerServiceStub{}).EligibleKeys(c) - require.Equal(t, http.StatusUnauthorized, recorder.Code) -} diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index ea0f4fc51f1b..bc65201df714 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -440,7 +440,6 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { requestPayloadHash = service.HashUsageRequestPayload([]byte(parsed.StickySessionSeed())) } usageSnapshot := snapshotOpenAIUsageMetadataWithHash(c, apiKey, account, subscription, channelMapping, requestModel, result, requestPayloadHash) - applyImageStudioUsageOrigin(c, usageSnapshot) usageSnapshot.upstreamEndpoint = GetUpstreamEndpoint(c, account.Platform) usageInput := usageSnapshot.Input(result, h.apiKeyService, service.OpenAIPricingAtFromContext(c.Request.Context())) usageAPIKeyID := usageInput.APIKey.ID diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 5f1341975aed..7d146eebad78 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1335,7 +1335,6 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { entry.ClientIP = &clientIP } - applyImageStudioOpsOrigin(c, entry) enqueueOpsErrorLog(ops, entry) } } @@ -1452,7 +1451,6 @@ func logOpsRecoveredUpstream(c *gin.Context, ops *service.OpsService, finalStatu entry.ClientIP = &clientIP } applyOpsLatencyFieldsFromContext(c, entry) - applyImageStudioOpsOrigin(c, entry) enqueueOpsErrorLog(ops, entry) } @@ -1645,7 +1643,6 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus entry.ClientIP = &clientIP } - applyImageStudioOpsOrigin(c, entry) enqueueOpsErrorLog(ops, entry) } diff --git a/backend/internal/handler/plugin_contract_fixture_test.go b/backend/internal/handler/plugin_contract_fixture_test.go deleted file mode 100644 index eb45f04bdd75..000000000000 --- a/backend/internal/handler/plugin_contract_fixture_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package handler - -import ( - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/Wei-Shaw/sub2api/internal/testextensions" -) - -func init() { testextensions.Install() } - -func enableImageStudioPolicy(t *testing.T) { - t.Helper() - testextensions.InstallImageTools(extensionv1.ImageToolsConfig{StudioEnabled: true}) - t.Cleanup(testextensions.Install) -} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index f79949080180..b63f80b77565 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -111,7 +111,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ImageStudioEnabled: settings.ImageStudioEnabled, FlatThemeEnabled: settings.FlatThemeEnabled, SubscriptionEnabled: settings.SubscriptionEnabled, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 33fae0f9cd0a..45fa2b65c779 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -212,7 +212,6 @@ func ProvideHandlers( modelPlazaHandler *ModelPlazaHandler, asyncImageHandler *AsyncImageHandler, batchImageHandler *BatchImageHandler, - imageStudioHandler *ImageStudioJobHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, ) *Handlers { @@ -238,7 +237,6 @@ func ProvideHandlers( ModelPlaza: modelPlazaHandler, AsyncImage: asyncImageHandler, BatchImage: batchImageHandler, - ImageStudio: imageStudioHandler, } } @@ -265,10 +263,6 @@ var ProviderSet = wire.NewSet( NewModelPlazaHandler, NewAsyncImageHandler, ProvideBatchImageHandler, - NewImageStudioJobHandler, - NewImageStudioGatewayExecutor, - wire.Bind(new(imageStudioImagesInvoker), new(*OpenAIGatewayHandler)), - ProvideImageStudioRuntime, // Admin handlers admin.NewDashboardHandler, @@ -326,12 +320,3 @@ func ProvidePluginHandler(manager *service.PluginManager, retired *service.Nativ handler.SetRetiredPlugins(retired) return handler } - -func ProvideImageStudioRuntime( - repo service.ImageStudioRepository, - studio *service.ImageStudioService, - store service.ImageStudioFileStorage, - executor *ImageStudioGatewayExecutor, -) *service.ImageStudioRuntime { - return service.NewImageStudioRuntime(repo, studio, store, executor, service.ImageStudioRuntimeOptions{}) -} diff --git a/backend/internal/nativeapi/account_import.go b/backend/internal/nativeapi/account_import.go deleted file mode 100644 index d7b28026dff5..000000000000 --- a/backend/internal/nativeapi/account_import.go +++ /dev/null @@ -1,29 +0,0 @@ -package nativeapi - -const ( - AccountImportCodeCreate = "account_import_create" - AccountImportCodeUpdate = "account_import_update" - AccountImportCodePayloadInvalid = "account_import_payload_invalid" - AccountImportCodeIdentityConflict = "account_import_identity_conflict" - AccountImportCodeExecutionFailed = "account_import_execution_failed" -) - -// Import policy sees validation facts and identity matches. The host keeps the -// imported credentials, names and proxy secrets. -type AccountImportItemFacts struct { - PayloadValid bool `json:"payload_valid"` - Matches []int64 `json:"matches,omitempty"` -} - -type AccountImportPlanningRequest struct { - Phase string `json:"phase,omitempty"` - Prepared []AccountImportItemPlan `json:"prepared,omitempty"` - Items []AccountImportItemFacts `json:"items"` -} - -type AccountImportItemPlan struct { - Index int `json:"index"` - Action string `json:"action"` - Code string `json:"code"` - AccountID int64 `json:"account_id,omitempty"` -} diff --git a/backend/internal/nativeapi/account_tools.go b/backend/internal/nativeapi/account_tools.go deleted file mode 100644 index 7006a5b80707..000000000000 --- a/backend/internal/nativeapi/account_tools.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -type TaxonomyName struct { - Name string `json:"name"` - Normalized string `json:"normalized,omitempty"` -} -type TaxonomyOrder struct { - Actual []int64 `json:"actual"` - Ordered []int64 `json:"ordered"` -} -type TaxonomyBulkPlan struct { - AccountIDs []int64 `json:"account_ids"` - HasFilters bool `json:"has_filters"` - ExpectedMatchCount *int `json:"expected_match_count,omitempty"` - FolderAction string `json:"folder_action"` - FolderID *int64 `json:"folder_id,omitempty"` - TagAddIDs []int64 `json:"tag_add_ids"` - TagRemoveIDs []int64 `json:"tag_remove_ids"` -} -type TaxonomyAssignmentPlan struct { - FolderID *int64 `json:"folder_id"` - TagIDs []int64 `json:"tag_ids"` -} -type ReasoningSelection struct { - Mode string `json:"mode"` - Effort string `json:"effort"` - Levels []string `json:"levels"` -} - -// Only bounded facts about a user prompt cross into the policy process. -type TextPromptSelection struct { - Characters int `json:"characters"` - ValidUTF8 bool `json:"valid_utf8"` -} diff --git a/backend/internal/nativeapi/api.go b/backend/internal/nativeapi/api.go deleted file mode 100644 index 561ad93aedb5..000000000000 --- a/backend/internal/nativeapi/api.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package nativeapi contains the shared payloads of the in-process domains. -// Stored JSON field names remain compatible with existing account data. -package nativeapi - -import ( - "context" - "encoding/json" -) - -const ( - // Preserve the existing bound on internal policy plans and catalog payloads. - MaxPayloadBytes = 4 * 1024 * 1024 -) - -// Invocation carries one domain operation and its payload. -type Invocation struct { - Operation string `json:"operation"` - Payload json.RawMessage `json:"payload"` -} - -type Result struct { - HTTPStatus int `json:"http_status,omitempty"` - Payload json.RawMessage `json:"payload,omitempty"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` -} - -type OperationInvoker interface { - InvokeOperation(context.Context, Invocation) (Result, error) -} diff --git a/backend/internal/nativeapi/doc.go b/backend/internal/nativeapi/doc.go new file mode 100644 index 000000000000..f9223531d791 --- /dev/null +++ b/backend/internal/nativeapi/doc.go @@ -0,0 +1,3 @@ +// Package nativeapi contains the data types shared by the in-process domains. +// Stored JSON field names remain compatible with existing account data. +package nativeapi diff --git a/backend/internal/nativeapi/image.go b/backend/internal/nativeapi/image.go deleted file mode 100644 index fdcec78b2b20..000000000000 --- a/backend/internal/nativeapi/image.go +++ /dev/null @@ -1,35 +0,0 @@ -package nativeapi - -type ImageToolsConfig struct { - StudioEnabled bool `json:"studio_enabled"` -} - -const ( - ImageStudioModelGPTImage2 = "gpt-image-2" - ImageStudioModelGeminiProImage = "gemini-3-pro-image" - ImageStudioMaxOutputCount = 4 -) - -// Image bytes, prompt text and API key material stay in the host. Only bounded -// request facts are needed to choose an image plan. -type ImageStudioPlanRequest struct { - APIKeySelected bool `json:"api_key_selected"` - Mode string `json:"mode"` - Model string `json:"model"` - PromptBytes int `json:"prompt_bytes"` - Count int `json:"count"` - Size string `json:"size"` - Quality string `json:"quality"` - HasReference bool `json:"has_reference"` - HasMask bool `json:"has_mask"` -} - -type ImageStudioPlan struct { - Model string `json:"model"` - Mode string `json:"mode"` - Size string `json:"size"` - Quality string `json:"quality"` - Endpoint string `json:"endpoint"` - OutputPerRequest int `json:"output_per_request"` - ResponseFormat string `json:"response_format"` -} diff --git a/backend/internal/repository/account_taxonomy_integration_test.go b/backend/internal/repository/account_taxonomy_integration_test.go index decd95b64fda..0c4eeff2dc10 100644 --- a/backend/internal/repository/account_taxonomy_integration_test.go +++ b/backend/internal/repository/account_taxonomy_integration_test.go @@ -528,10 +528,8 @@ func TestAccountTaxonomyBulkUpdateAndOrderingIntegration(t *testing.T) { require.Equal(t, folderA.ID, second.ManagementFolder.ID) require.Equal(t, tagA.ID, second.Tags[0].ID) - expected := 2 result, err = admin.BulkUpdateAccountTaxonomy(ctx, service.BulkAccountTaxonomyInput{ - Filters: &service.BulkUpdateAccountFilters{Console: &service.AccountConsoleFilters{Search: prefix, SortBy: "id", SortOrder: "asc"}}, - ExpectedMatchCount: &expected, FolderAction: "clear", TagAddIDs: []int64{tagC.ID}, + AccountIDs: accountIDs, FolderAction: "clear", TagAddIDs: []int64{tagC.ID}, }) require.NoError(t, err) require.Equal(t, 2, result.MatchedCount) @@ -549,19 +547,6 @@ func TestAccountTaxonomyBulkUpdateAndOrderingIntegration(t *testing.T) { require.Equal(t, 1, bindingCount) } - wrongExpected := 3 - _, err = admin.BulkUpdateAccountTaxonomy(ctx, service.BulkAccountTaxonomyInput{ - Filters: &service.BulkUpdateAccountFilters{Console: &service.AccountConsoleFilters{Search: prefix, SortBy: "id", SortOrder: "asc"}}, - ExpectedMatchCount: &wrongExpected, FolderAction: "set", FolderID: &folderA.ID, - }) - require.Error(t, err) - require.Equal(t, 409, infraerrors.Code(err)) - for _, accountID := range accountIDs { - loaded, loadErr := admin.GetAccount(ctx, accountID) - require.NoError(t, loadErr) - require.Nil(t, loaded.ManagementFolder) - } - missingTagID := int64(9223372036854770000) _, err = admin.BulkUpdateAccountTaxonomy(ctx, service.BulkAccountTaxonomyInput{ AccountIDs: []int64{accountIDs[0]}, FolderAction: "set", FolderID: &folderA.ID, TagAddIDs: []int64{missingTagID}, diff --git a/backend/internal/repository/db_pool_test.go b/backend/internal/repository/db_pool_test.go index a1bcbb82fdd8..2757f97c9029 100644 --- a/backend/internal/repository/db_pool_test.go +++ b/backend/internal/repository/db_pool_test.go @@ -1,11 +1,14 @@ package repository import ( + "database/sql" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" + + _ "github.com/lib/pq" ) func TestClampDBPoolSettings(t *testing.T) { @@ -76,7 +79,7 @@ func TestApplyDBPoolSettings(t *testing.T) { }, } - db, err := OpenPostgresDB("host=127.0.0.1 port=5432 user=postgres sslmode=disable") + db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres sslmode=disable") require.NoError(t, err) t.Cleanup(func() { _ = db.Close() diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index 2f21aa553e42..7f075218ce95 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -281,18 +281,6 @@ func openAIRuntimeBreakerBaseKey(accountID int64, model string) string { return fmt.Sprintf("%s%d:%s", openAIRuntimeBreakerPrefix, accountID, openAIRuntimeBreakerScope(model)) } -func openAIRuntimeBreakerBlockKey(accountID int64, model string) string { - return openAIRuntimeBreakerBaseKey(accountID, model) + ":block" -} - -func openAIRuntimeBreakerMarkerKey(accountID int64, model string) string { - return openAIRuntimeBreakerBaseKey(accountID, model) + ":marker" -} - -func openAIRuntimeBreakerClaimKey(accountID int64, model string) string { - return openAIRuntimeBreakerBaseKey(accountID, model) + ":claim" -} - func openAIRuntimeBreakerIndexKey(accountID int64) string { return fmt.Sprintf("%s%d:index", openAIRuntimeBreakerPrefix, accountID) } diff --git a/backend/internal/repository/gateway_cache_live_test.go b/backend/internal/repository/gateway_cache_live_test.go index b0e9aded6c72..d5672e8d958b 100644 --- a/backend/internal/repository/gateway_cache_live_test.go +++ b/backend/internal/repository/gateway_cache_live_test.go @@ -11,6 +11,18 @@ import ( "github.com/stretchr/testify/require" ) +func openAIRuntimeBreakerBlockKey(accountID int64, model string) string { + return openAIRuntimeBreakerBaseKey(accountID, model) + ":block" +} + +func openAIRuntimeBreakerMarkerKey(accountID int64, model string) string { + return openAIRuntimeBreakerBaseKey(accountID, model) + ":marker" +} + +func openAIRuntimeBreakerClaimKey(accountID int64, model string) string { + return openAIRuntimeBreakerBaseKey(accountID, model) + ":claim" +} + func TestGatewayCacheLiveCallIdentityAndController(t *testing.T) { redisServer := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) diff --git a/backend/internal/repository/image_studio_drop_migration_integration_test.go b/backend/internal/repository/image_studio_drop_migration_integration_test.go new file mode 100644 index 000000000000..f6ccaee19f24 --- /dev/null +++ b/backend/internal/repository/image_studio_drop_migration_integration_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "strings" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const ( + imageStudioDropMigration = "270_drop_image_studio.sql" + imageStudioTablesMigration = "233_image_studio_jobs.sql" +) + +// TestMain applied every migration, 270 included, to an empty database: the +// fresh-database run. The stored case restores the tables 270 drops by running +// 233 again, stores the rows a database of the previous release can hold next +// to rows that must stay, applies 270 twice and checks what went and what +// stayed. +func TestMigration270DropsImageStudio(t *testing.T) { + ctx := context.Background() + read := func(name string) string { + t.Helper() + content, err := dbmigrations.FS.ReadFile(name) + require.NoError(t, err) + return string(content) + } + migration, tables := read(imageStudioDropMigration), read(imageStudioTablesMigration) + + type fixture struct { + tx *sql.Tx + t *testing.T + } + open := func(t *testing.T) fixture { return fixture{tx: testTx(t), t: t} } + exec := func(f fixture, query string, args ...any) { + f.t.Helper() + _, err := f.tx.ExecContext(ctx, query, args...) + require.NoError(f.t, err) + } + text := func(f fixture, query string, args ...any) string { + f.t.Helper() + var value string + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(f fixture, query string, args ...any) int { + f.t.Helper() + var value int + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + put := func(f fixture, key, value string) { + f.t.Helper() + exec(f, `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, value) + } + ctid := func(f fixture, table, column string, key any) string { + f.t.Helper() + return text(f, `SELECT ctid::text FROM `+table+` WHERE `+column+` = $1`, key) + } + // The Image Studio switch. + switchRows := func(f fixture) int { + f.t.Helper() + return count(f, `SELECT count(*) FROM settings WHERE key = 'image_tools_config'`) + } + // Tables, indexes and sequences of 233, and the constraints that name them. + storedObjects := func(f fixture) int { + f.t.Helper() + return count(f, `SELECT (SELECT count(*) FROM pg_class WHERE relname LIKE 'image\_studio\_%') + + (SELECT count(*) FROM pg_constraint WHERE conname LIKE 'image\_studio\_%')`) + } + restoreTables := func(f fixture) { + f.t.Helper() + require.Zero(f.t, storedObjects(f)) + exec(f, tables) + require.Equal(f.t, 3, count(f, `SELECT count(*) FROM unnest(ARRAY['image_studio_jobs', 'image_studio_items', 'image_studio_artifacts']) AS name WHERE to_regclass(name) IS NOT NULL`)) + } + // One finished job with its item and stored image record; returns the + // owner and the API key the job references. + storeJob := func(f fixture, name string) (userID, keyID int64) { + f.t.Helper() + var jobID, itemID int64 + require.NoError(f.t, f.tx.QueryRowContext(ctx, `INSERT INTO users (email, password_hash, balance) VALUES ($1, 'fixture', 42) RETURNING id`, name+"@example.invalid").Scan(&userID)) + require.NoError(f.t, f.tx.QueryRowContext(ctx, `INSERT INTO api_keys (user_id, key, name) VALUES ($1, $2, 'migration-270') RETURNING id`, userID, name+"-fixture-key").Scan(&keyID)) + require.NoError(f.t, f.tx.QueryRowContext(ctx, ` +INSERT INTO image_studio_jobs (user_id, api_key_id, mode, model, prompt, count, status, processed_count, succeeded_count) +VALUES ($1, $2, 'generate', 'gpt-image-2', 'fixture prompt', 1, 'succeeded', 1, 1) RETURNING id`, userID, keyID).Scan(&jobID)) + require.NoError(f.t, f.tx.QueryRowContext(ctx, `INSERT INTO image_studio_items (job_id, ordinal, status) VALUES ($1, 1, 'succeeded') RETURNING id`, jobID).Scan(&itemID)) + exec(f, `INSERT INTO image_studio_artifacts (job_id, item_id, kind, storage_key, content_type, byte_size, expires_at) + VALUES ($1, $2, 'output', $3, 'image/png', 68, NOW() + INTERVAL '1 day')`, jobID, itemID, strings.Repeat("a", 32)+".png") + return userID, keyID + } + apply := func(f fixture) { f.t.Helper(); exec(f, migration) } + + t.Run("fresh database", func(t *testing.T) { + f := open(t) + require.Zero(t, storedObjects(f), "270 already dropped the tables 233 created") + require.Zero(t, switchRows(f)) + apply(f) + require.Zero(t, storedObjects(f)) + require.Zero(t, switchRows(f)) + }) + + t.Run("stored jobs and the switch", func(t *testing.T) { + f := open(t) + restoreTables(f) + + userID, keyID := storeJob(f, "migration-270-stored") + put(f, "image_tools_config", `{"studio_enabled":true}`) + put(f, "admin_observability_config", `{"telemetry_enabled":true,"theme_enabled":false}`) + put(f, "image_tools_config_note", "kept: not the Image Studio switch") + exec(f, `INSERT INTO sub2api_plugin_installations + (plugin_key, name, version, manifest, artifact_path, install_path, binary_path, binary_sha256, config_encrypted, artifact_data) + VALUES ('codexrip.image-tools', 'fixture', '0.2.13', '{"id":"codexrip.image-tools"}', '/data/plugins/fixture.zip', + '/data/plugins/fixture', '/data/plugins/fixture/plugin', repeat('b', 64), 'fixture-config-cipher', '\x040506')`) + + // The row versions of everything 270 must leave alone, and every column + // of the plugin installation. + kept := func() map[string]string { + return map[string]string{ + "user": ctid(f, "users", "id", userID), + "api key": ctid(f, "api_keys", "id", keyID), + "observability": ctid(f, "settings", "key", "admin_observability_config"), + "similar key": ctid(f, "settings", "key", "image_tools_config_note"), + "other settings": text(f, `SELECT count(*)::text FROM settings WHERE key <> 'image_tools_config'`), + "installation": text(f, `SELECT ctid::text || '/' || i::text FROM sub2api_plugin_installations AS i WHERE plugin_key = 'codexrip.image-tools'`), + } + } + before := kept() + + for run := 1; run <= 2; run++ { + apply(f) + require.Zero(t, storedObjects(f), "run %d", run) + require.Zero(t, switchRows(f), "run %d", run) + require.Equal(t, before, kept(), "run %d changes nothing else", run) + } + }) +} diff --git a/backend/internal/repository/image_studio_job_repo.go b/backend/internal/repository/image_studio_job_repo.go deleted file mode 100644 index effa40be769f..000000000000 --- a/backend/internal/repository/image_studio_job_repo.go +++ /dev/null @@ -1,638 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type imageStudioRepository struct { - db *sql.DB -} - -func NewImageStudioRepository(db *sql.DB) service.ImageStudioRepository { - return &imageStudioRepository{db: db} -} - -const imageStudioJobSelectColumns = `id, user_id, api_key_id, mode, model, prompt, size, quality, count, status, - processed_count, succeeded_count, failed_count, canceled_count, cancel_requested_at, - error_code, error_message, request_expires_at, retain_until, started_at, finished_at, created_at, updated_at` - -const imageStudioArtifactSelectColumns = `id, job_id, item_id, kind, storage_key, content_type, - byte_size, revised_prompt, expires_at, created_at` - -type imageStudioScanner interface { - Scan(...any) error -} - -func scanImageStudioJob(row imageStudioScanner) (*service.ImageStudioJob, error) { - job := &service.ImageStudioJob{} - var mode, status string - var cancelRequested, started, finished sql.NullTime - var errorCode, errorMessage sql.NullString - err := row.Scan( - &job.ID, &job.UserID, &job.APIKeyID, &mode, &job.Model, &job.Prompt, &job.Size, &job.Quality, - &job.Count, &status, &job.Counts.Processed, &job.Counts.Succeeded, &job.Counts.Failed, - &job.Counts.Canceled, &cancelRequested, &errorCode, &errorMessage, &job.RequestExpiresAt, - &job.RetainUntil, &started, &finished, &job.CreatedAt, &job.UpdatedAt, - ) - if err != nil { - return nil, err - } - job.Mode = service.ImageStudioMode(mode) - job.Status = service.ImageStudioJobStatus(status) - if cancelRequested.Valid { - job.CancelRequestedAt = &cancelRequested.Time - } - if errorCode.Valid { - job.ErrorCode = errorCode.String - } - if errorMessage.Valid { - job.ErrorMessage = errorMessage.String - } - if started.Valid { - job.StartedAt = &started.Time - } - if finished.Valid { - job.FinishedAt = &finished.Time - } - return job, nil -} - -func scanImageStudioItem(row imageStudioScanner) (*service.ImageStudioItem, error) { - item := &service.ImageStudioItem{} - var status string - var errorCode, errorMessage sql.NullString - var started, finished sql.NullTime - err := row.Scan( - &item.ID, &item.JobID, &item.Ordinal, &status, &errorCode, &errorMessage, - &started, &finished, &item.CreatedAt, &item.UpdatedAt, - ) - if err != nil { - return nil, err - } - item.Status = service.ImageStudioItemStatus(status) - if errorCode.Valid { - item.ErrorCode = errorCode.String - } - if errorMessage.Valid { - item.ErrorMessage = errorMessage.String - } - if started.Valid { - item.StartedAt = &started.Time - } - if finished.Valid { - item.FinishedAt = &finished.Time - } - return item, nil -} - -func scanImageStudioArtifact(row imageStudioScanner) (*service.ImageStudioArtifact, error) { - artifact := &service.ImageStudioArtifact{} - var itemID sql.NullInt64 - var kind string - var revisedPrompt sql.NullString - err := row.Scan( - &artifact.ID, &artifact.JobID, &itemID, &kind, &artifact.StorageKey, &artifact.ContentType, - &artifact.ByteSize, &revisedPrompt, &artifact.ExpiresAt, &artifact.CreatedAt, - ) - if err != nil { - return nil, err - } - artifact.Kind = service.ImageStudioArtifactKind(kind) - if itemID.Valid { - artifact.ItemID = &itemID.Int64 - } - if revisedPrompt.Valid { - artifact.RevisedPrompt = revisedPrompt.String - } - return artifact, nil -} - -func (r *imageStudioRepository) Create(ctx context.Context, params service.ImageStudioCreateParams) (*service.ImageStudioJob, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - - job, err := scanImageStudioJob(tx.QueryRowContext(ctx, `INSERT INTO image_studio_jobs - (user_id, api_key_id, mode, model, prompt, size, quality, count, request_expires_at, retain_until) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) - RETURNING `+imageStudioJobSelectColumns, - params.UserID, params.Input.APIKeyID, params.Input.Mode, params.Input.Model, params.Input.Prompt, - params.Input.Size, params.Input.Quality, params.Input.Count, params.RequestExpiresAt, params.RetainUntil, - )) - if err != nil { - if isUniqueViolation(err) { - return nil, service.ErrImageStudioActiveJob - } - return nil, err - } - for ordinal := 1; ordinal <= params.Input.Count; ordinal++ { - if _, err = tx.ExecContext(ctx, - `INSERT INTO image_studio_items (job_id, ordinal) VALUES ($1,$2)`, job.ID, ordinal); err != nil { - return nil, err - } - } - for _, artifact := range params.InputArtifacts { - if _, err = tx.ExecContext(ctx, `INSERT INTO image_studio_artifacts - (job_id, kind, storage_key, content_type, byte_size, expires_at) - VALUES ($1,$2,$3,$4,$5,$6)`, job.ID, artifact.Kind, artifact.StorageKey, - artifact.ContentType, artifact.ByteSize, params.RequestExpiresAt); err != nil { - return nil, err - } - } - if err = tx.Commit(); err != nil { - return nil, err - } - return job, nil -} - -func (r *imageStudioRepository) Get(ctx context.Context, userID, jobID int64) (*service.ImageStudioJob, error) { - job, err := scanImageStudioJob(r.db.QueryRowContext(ctx, `SELECT `+imageStudioJobSelectColumns+` - FROM image_studio_jobs WHERE id=$1 AND user_id=$2`, jobID, userID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, service.ErrImageStudioNotFound - } - return job, err -} - -func (r *imageStudioRepository) List(ctx context.Context, userID int64, limit, offset int) ([]service.ImageStudioJob, error) { - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - if offset < 0 { - offset = 0 - } - rows, err := r.db.QueryContext(ctx, `SELECT `+imageStudioJobSelectColumns+` - FROM image_studio_jobs WHERE user_id=$1 ORDER BY created_at DESC, id DESC LIMIT $2 OFFSET $3`, - userID, limit, offset) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - jobs := make([]service.ImageStudioJob, 0, limit) - for rows.Next() { - job, scanErr := scanImageStudioJob(rows) - if scanErr != nil { - return nil, scanErr - } - jobs = append(jobs, *job) - } - return jobs, rows.Err() -} - -func (r *imageStudioRepository) ListItems(ctx context.Context, userID, jobID int64) ([]service.ImageStudioItem, error) { - rows, err := r.db.QueryContext(ctx, `SELECT `+prefixedImageStudioItemColumns("item")+` - FROM image_studio_items item JOIN image_studio_jobs job ON job.id=item.job_id - WHERE item.job_id=$1 AND job.user_id=$2 ORDER BY item.ordinal`, jobID, userID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - items := make([]service.ImageStudioItem, 0, service.ImageStudioMaxOutputCount) - for rows.Next() { - item, scanErr := scanImageStudioItem(rows) - if scanErr != nil { - return nil, scanErr - } - items = append(items, *item) - } - if err = rows.Err(); err != nil { - return nil, err - } - if len(items) == 0 { - if _, getErr := r.Get(ctx, userID, jobID); getErr != nil { - return nil, getErr - } - } - return items, nil -} - -func (r *imageStudioRepository) ListOutputArtifacts(ctx context.Context, userID, jobID int64) ([]service.ImageStudioArtifact, error) { - rows, err := r.db.QueryContext(ctx, `SELECT `+prefixedImageStudioArtifactColumns("artifact")+` - FROM image_studio_artifacts artifact JOIN image_studio_jobs job ON job.id=artifact.job_id - WHERE artifact.job_id=$1 AND job.user_id=$2 AND artifact.kind='output' - ORDER BY artifact.item_id, artifact.id`, jobID, userID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - artifacts := make([]service.ImageStudioArtifact, 0) - for rows.Next() { - artifact, scanErr := scanImageStudioArtifact(rows) - if scanErr != nil { - return nil, scanErr - } - artifacts = append(artifacts, *artifact) - } - return artifacts, rows.Err() -} - -func (r *imageStudioRepository) GetArtifact(ctx context.Context, userID, jobID, artifactID int64) (*service.ImageStudioArtifact, error) { - artifact, err := scanImageStudioArtifact(r.db.QueryRowContext(ctx, `SELECT `+prefixedImageStudioArtifactColumns("artifact")+` - FROM image_studio_artifacts artifact JOIN image_studio_jobs job ON job.id=artifact.job_id - WHERE artifact.id=$1 AND artifact.job_id=$2 AND job.user_id=$3 AND artifact.kind='output'`, - artifactID, jobID, userID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, service.ErrImageStudioNotFound - } - return artifact, err -} - -func prefixedImageStudioItemColumns(prefix string) string { - return prefix + `.id, ` + prefix + `.job_id, ` + prefix + `.ordinal, ` + prefix + `.status, ` + - prefix + `.error_code, ` + prefix + `.error_message, ` + prefix + `.started_at, ` + - prefix + `.finished_at, ` + prefix + `.created_at, ` + prefix + `.updated_at` -} - -func prefixedImageStudioArtifactColumns(prefix string) string { - return prefix + `.id, ` + prefix + `.job_id, ` + prefix + `.item_id, ` + prefix + `.kind, ` + - prefix + `.storage_key, ` + prefix + `.content_type, ` + prefix + `.byte_size, ` + - prefix + `.revised_prompt, ` + prefix + `.expires_at, ` + prefix + `.created_at` -} - -func (r *imageStudioRepository) RequestCancel(ctx context.Context, userID, jobID int64) (*service.ImageStudioJob, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - job, err := getImageStudioJobForUpdate(ctx, tx, userID, jobID) - if err != nil { - return nil, err - } - if job.Terminal() { - if err = tx.Commit(); err != nil { - return nil, err - } - return job, nil - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_jobs - SET cancel_requested_at=COALESCE(cancel_requested_at,NOW()), updated_at=NOW() - WHERE id=$1`, jobID); err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_items - SET status='canceled', error_code='canceled', error_message='canceled by user', - finished_at=NOW(), updated_at=NOW() - WHERE job_id=$1 AND status='pending'`, jobID); err != nil { - return nil, err - } - if err = refreshImageStudioCounts(ctx, tx, jobID); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - return r.Finalize(ctx, jobID) -} - -func (r *imageStudioRepository) Retry(ctx context.Context, userID, jobID int64, now time.Time) (*service.ImageStudioJob, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - job, err := getImageStudioJobForUpdate(ctx, tx, userID, jobID) - if err != nil { - return nil, err - } - if job.Status != service.ImageStudioJobFailed { - return nil, service.ErrImageStudioNotRetryable - } - if !job.RequestExpiresAt.After(now) || job.Prompt == "" { - return nil, service.ErrImageStudioRequestExpired - } - result, err := tx.ExecContext(ctx, `UPDATE image_studio_items - SET status='pending', error_code=NULL, error_message=NULL, started_at=NULL, finished_at=NULL, updated_at=NOW() - WHERE job_id=$1 AND status IN ('running','failed','canceled')`, jobID) - if err != nil { - return nil, err - } - changed, err := result.RowsAffected() - if err != nil { - return nil, err - } - if changed == 0 { - return nil, service.ErrImageStudioNotRetryable - } - job, err = scanImageStudioJob(tx.QueryRowContext(ctx, `UPDATE image_studio_jobs - SET status='pending', cancel_requested_at=NULL, error_code=NULL, error_message=NULL, - processed_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=$1 AND status='succeeded'), - succeeded_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=$1 AND status='succeeded'), - failed_count=0, canceled_count=0, started_at=NULL, finished_at=NULL, updated_at=NOW() - WHERE id=$1 RETURNING `+imageStudioJobSelectColumns, jobID)) - if err != nil { - if isUniqueViolation(err) { - return nil, service.ErrImageStudioActiveJob - } - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - return job, nil -} - -func getImageStudioJobForUpdate(ctx context.Context, tx *sql.Tx, userID, jobID int64) (*service.ImageStudioJob, error) { - job, err := scanImageStudioJob(tx.QueryRowContext(ctx, `SELECT `+imageStudioJobSelectColumns+` - FROM image_studio_jobs WHERE id=$1 AND user_id=$2 FOR UPDATE`, jobID, userID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, service.ErrImageStudioNotFound - } - return job, err -} - -func (r *imageStudioRepository) RecoverInterrupted(ctx context.Context) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_items item - SET status='failed', error_code='interrupted', error_message='image generation was interrupted', - finished_at=NOW(), updated_at=NOW() - FROM image_studio_jobs job - WHERE item.job_id=job.id AND job.status IN ('preparing','running') AND item.status IN ('pending','running')`); err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_jobs - SET status='failed', error_code='interrupted', error_message='image generation was interrupted', - processed_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=image_studio_jobs.id AND status IN ('succeeded','failed','canceled')), - succeeded_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=image_studio_jobs.id AND status='succeeded'), - failed_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=image_studio_jobs.id AND status='failed'), - canceled_count=(SELECT COUNT(*)::smallint FROM image_studio_items WHERE job_id=image_studio_jobs.id AND status='canceled'), - finished_at=NOW(), updated_at=NOW() - WHERE status IN ('preparing','running')`); err != nil { - return err - } - return tx.Commit() -} - -func (r *imageStudioRepository) ClaimNext(ctx context.Context) (*service.ImageStudioClaim, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - - item, err := scanImageStudioItem(tx.QueryRowContext(ctx, `WITH picked AS ( - SELECT item.id FROM image_studio_items item - JOIN image_studio_jobs job ON job.id=item.job_id - WHERE item.status='pending' AND job.status IN ('pending','running') - AND job.cancel_requested_at IS NULL AND job.request_expires_at > NOW() - ORDER BY job.created_at, item.ordinal - FOR UPDATE OF item SKIP LOCKED LIMIT 1 - ) - UPDATE image_studio_items item SET status='running', started_at=NOW(), updated_at=NOW() - FROM picked WHERE item.id=picked.id RETURNING `+prefixedImageStudioItemColumns("item"))) - if errors.Is(err, sql.ErrNoRows) { - return nil, service.ErrImageStudioNoWork - } - if err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_jobs - SET status='running', started_at=COALESCE(started_at,NOW()), updated_at=NOW() - WHERE id=$1 AND status IN ('pending','preparing','running')`, item.JobID); err != nil { - return nil, err - } - job, err := scanImageStudioJob(tx.QueryRowContext(ctx, `SELECT `+imageStudioJobSelectColumns+` - FROM image_studio_jobs WHERE id=$1 FOR UPDATE`, item.JobID)) - if err != nil { - return nil, err - } - rows, err := tx.QueryContext(ctx, `SELECT `+imageStudioArtifactSelectColumns+` - FROM image_studio_artifacts WHERE job_id=$1 AND item_id IS NULL - AND kind IN ('reference','mask') AND expires_at > NOW() ORDER BY id`, item.JobID) - if err != nil { - return nil, err - } - inputs := make([]service.ImageStudioArtifact, 0, 2) - for rows.Next() { - artifact, scanErr := scanImageStudioArtifact(rows) - if scanErr != nil { - _ = rows.Close() - return nil, scanErr - } - inputs = append(inputs, *artifact) - } - if err = rows.Close(); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - return &service.ImageStudioClaim{Job: *job, Item: *item, Inputs: inputs}, nil -} - -func (r *imageStudioRepository) CompleteSuccess(ctx context.Context, itemID int64, artifact service.ImageStudioInputArtifact, revisedPrompt string, expiresAt time.Time) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - var jobID int64 - if err = tx.QueryRowContext(ctx, `SELECT job_id FROM image_studio_items WHERE id=$1 AND status='running' FOR UPDATE`, itemID).Scan(&jobID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return service.ErrImageStudioNotFound - } - return err - } - if _, err = tx.ExecContext(ctx, `INSERT INTO image_studio_artifacts - (job_id, item_id, kind, storage_key, content_type, byte_size, revised_prompt, expires_at) - VALUES ($1,$2,'output',$3,$4,$5,NULLIF($6,''),$7)`, jobID, itemID, artifact.StorageKey, - artifact.ContentType, artifact.ByteSize, revisedPrompt, expiresAt); err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_items - SET status='succeeded', error_code=NULL, error_message=NULL, finished_at=NOW(), updated_at=NOW() - WHERE id=$1 AND status='running'`, itemID); err != nil { - return err - } - if err = refreshImageStudioCounts(ctx, tx, jobID); err != nil { - return err - } - return tx.Commit() -} - -func (r *imageStudioRepository) CompleteFailure(ctx context.Context, itemID int64, code, message string) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - var jobID int64 - if err = tx.QueryRowContext(ctx, `UPDATE image_studio_items - SET status='failed', error_code=$2, error_message=$3, finished_at=NOW(), updated_at=NOW() - WHERE id=$1 AND status='running' RETURNING job_id`, itemID, code, message).Scan(&jobID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return service.ErrImageStudioNotFound - } - return err - } - if err = refreshImageStudioCounts(ctx, tx, jobID); err != nil { - return err - } - return tx.Commit() -} - -func refreshImageStudioCounts(ctx context.Context, tx *sql.Tx, jobID int64) error { - _, err := tx.ExecContext(ctx, `UPDATE image_studio_jobs job SET - processed_count=counts.processed, - succeeded_count=counts.succeeded, - failed_count=counts.failed, - canceled_count=counts.canceled, - updated_at=NOW() - FROM ( - SELECT COUNT(*) FILTER (WHERE status IN ('succeeded','failed','canceled'))::smallint AS processed, - COUNT(*) FILTER (WHERE status='succeeded')::smallint AS succeeded, - COUNT(*) FILTER (WHERE status='failed')::smallint AS failed, - COUNT(*) FILTER (WHERE status='canceled')::smallint AS canceled - FROM image_studio_items WHERE job_id=$1 - ) counts WHERE job.id=$1`, jobID) - return err -} - -func (r *imageStudioRepository) Finalize(ctx context.Context, jobID int64) (*service.ImageStudioJob, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - job, err := scanImageStudioJob(tx.QueryRowContext(ctx, `SELECT `+imageStudioJobSelectColumns+` - FROM image_studio_jobs WHERE id=$1 FOR UPDATE`, jobID)) - if errors.Is(err, sql.ErrNoRows) { - return nil, service.ErrImageStudioNotFound - } - if err != nil { - return nil, err - } - var active int - if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM image_studio_items - WHERE job_id=$1 AND status IN ('pending','running')`, jobID).Scan(&active); err != nil { - return nil, err - } - if active == 0 && !job.Terminal() { - if err = refreshImageStudioCounts(ctx, tx, jobID); err != nil { - return nil, err - } - var counts service.ImageStudioCounts - if err = tx.QueryRowContext(ctx, `SELECT processed_count, succeeded_count, failed_count, canceled_count - FROM image_studio_jobs WHERE id=$1`, jobID).Scan(&counts.Processed, &counts.Succeeded, &counts.Failed, &counts.Canceled); err != nil { - return nil, err - } - status := service.ResolveImageStudioTerminalStatus(job.CancelRequestedAt != nil, counts) - errorCode, errorMessage := "", "" - if status == service.ImageStudioJobFailed { - errorCode, errorMessage = "generation_failed", "Image generation failed" - } - job, err = scanImageStudioJob(tx.QueryRowContext(ctx, `UPDATE image_studio_jobs - SET status=$2, error_code=NULLIF($3,''), error_message=NULLIF($4,''), finished_at=NOW(), updated_at=NOW() - WHERE id=$1 RETURNING `+imageStudioJobSelectColumns, jobID, status, errorCode, errorMessage)) - if err != nil { - return nil, err - } - } - if err = tx.Commit(); err != nil { - return nil, err - } - return job, nil -} - -func (r *imageStudioRepository) StorageUsage(ctx context.Context, userID int64, now time.Time) (service.ImageStudioStorageUsage, error) { - var usage service.ImageStudioStorageUsage - err := r.db.QueryRowContext(ctx, `SELECT - COALESCE(SUM(artifact.byte_size),0), - COALESCE(SUM(artifact.byte_size) FILTER (WHERE job.user_id=$1),0) - FROM image_studio_artifacts artifact - JOIN image_studio_jobs job ON job.id=artifact.job_id - WHERE artifact.expires_at > $2`, userID, now).Scan(&usage.Global, &usage.User) - return usage, err -} - -func (r *imageStudioRepository) ListExpiredArtifacts(ctx context.Context, now time.Time, limit int) ([]service.ImageStudioArtifact, error) { - if limit <= 0 || limit > 500 { - limit = 100 - } - rows, err := r.db.QueryContext(ctx, `SELECT `+imageStudioArtifactSelectColumns+` - FROM image_studio_artifacts WHERE expires_at <= $1 ORDER BY expires_at, id LIMIT $2`, now, limit) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - artifacts := make([]service.ImageStudioArtifact, 0, limit) - for rows.Next() { - artifact, scanErr := scanImageStudioArtifact(rows) - if scanErr != nil { - return nil, scanErr - } - artifacts = append(artifacts, *artifact) - } - return artifacts, rows.Err() -} - -func (r *imageStudioRepository) DeleteArtifact(ctx context.Context, artifactID int64) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM image_studio_artifacts WHERE id=$1`, artifactID) - if err != nil { - return err - } - if affected, rowsErr := result.RowsAffected(); rowsErr != nil { - return rowsErr - } else if affected == 0 { - return service.ErrImageStudioNotFound - } - return nil -} - -func (r *imageStudioRepository) ExpireRequests(ctx context.Context, now time.Time) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_items item - SET status='failed', error_code='request_expired', error_message='Image Studio request expired', - finished_at=NOW(), updated_at=NOW() - FROM image_studio_jobs job - WHERE item.job_id=job.id AND job.status='pending' AND job.request_expires_at <= $1 - AND item.status='pending'`, now); err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_jobs job - SET status='failed', prompt='', error_code='request_expired', error_message='Image Studio request expired', - processed_count=counts.processed, succeeded_count=counts.succeeded, - failed_count=counts.failed, canceled_count=counts.canceled, - finished_at=NOW(), updated_at=NOW() - FROM ( - SELECT job_id, - COUNT(*) FILTER (WHERE status IN ('succeeded','failed','canceled'))::smallint AS processed, - COUNT(*) FILTER (WHERE status='succeeded')::smallint AS succeeded, - COUNT(*) FILTER (WHERE status='failed')::smallint AS failed, - COUNT(*) FILTER (WHERE status='canceled')::smallint AS canceled - FROM image_studio_items GROUP BY job_id - ) counts - WHERE job.id=counts.job_id AND job.status='pending' AND job.request_expires_at <= $1`, now); err != nil { - return err - } - if _, err = tx.ExecContext(ctx, `UPDATE image_studio_jobs SET prompt='', updated_at=NOW() - WHERE request_expires_at <= $1 AND prompt <> ''`, now); err != nil { - return err - } - return tx.Commit() -} - -func (r *imageStudioRepository) DeleteExpiredJobs(ctx context.Context, now time.Time) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM image_studio_jobs job - WHERE job.retain_until <= $1 - AND NOT EXISTS (SELECT 1 FROM image_studio_artifacts artifact WHERE artifact.job_id=job.id)`, now) - return err -} diff --git a/backend/internal/repository/image_studio_job_repo_test.go b/backend/internal/repository/image_studio_job_repo_test.go deleted file mode 100644 index c70ad724e7fd..000000000000 --- a/backend/internal/repository/image_studio_job_repo_test.go +++ /dev/null @@ -1,104 +0,0 @@ -//go:build unit - -package repository - -import ( - "context" - "database/sql" - "regexp" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestImageStudioRepositoryCreatePersistsOneItemPerRequestedImage(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - now := time.Date(2026, 8, 21, 10, 0, 0, 0, time.UTC) - requestExpires := now.Add(service.ImageStudioFileRetention) - retainUntil := now.Add(service.ImageStudioMetadataRetention) - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO image_studio_jobs")). - WithArgs(int64(7), int64(11), service.ImageStudioModeEdit, service.ImageStudioModelGeminiProImage, - "replace the sky", "1024x1024", "low", 4, requestExpires, retainUntil). - WillReturnRows(imageStudioJobRows(now).AddRow( - int64(41), int64(7), int64(11), service.ImageStudioModeEdit, service.ImageStudioModelGeminiProImage, - "replace the sky", "1024x1024", "low", 4, service.ImageStudioJobPending, - 0, 0, 0, 0, nil, nil, nil, requestExpires, retainUntil, nil, nil, now, now, - )) - for ordinal := 1; ordinal <= 4; ordinal++ { - mock.ExpectExec(regexp.QuoteMeta("INSERT INTO image_studio_items")). - WithArgs(int64(41), ordinal). - WillReturnResult(sqlmock.NewResult(int64(ordinal), 1)) - } - mock.ExpectExec(regexp.QuoteMeta("INSERT INTO image_studio_artifacts")). - WithArgs(int64(41), service.ImageStudioArtifactReference, "ref-key", "image/png", int64(12), requestExpires). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectExec(regexp.QuoteMeta("INSERT INTO image_studio_artifacts")). - WithArgs(int64(41), service.ImageStudioArtifactMask, "mask-key", "image/png", int64(8), requestExpires). - WillReturnResult(sqlmock.NewResult(2, 1)) - mock.ExpectCommit() - - repo := NewImageStudioRepository(db) - job, err := repo.Create(context.Background(), service.ImageStudioCreateParams{ - UserID: 7, - Input: service.ImageStudioCreateInput{ - APIKeyID: 11, Mode: service.ImageStudioModeEdit, Model: service.ImageStudioModelGeminiProImage, - Prompt: "replace the sky", Size: "1024x1024", Quality: "low", Count: 4, - }, - RequestExpiresAt: requestExpires, - RetainUntil: retainUntil, - InputArtifacts: []service.ImageStudioInputArtifact{ - {Kind: service.ImageStudioArtifactReference, StorageKey: "ref-key", ContentType: "image/png", ByteSize: 12}, - {Kind: service.ImageStudioArtifactMask, StorageKey: "mask-key", ContentType: "image/png", ByteSize: 8}, - }, - }) - - require.NoError(t, err) - require.Equal(t, int64(41), job.ID) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestImageStudioRepositoryGetScopesJobToSessionOwner(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - mock.ExpectQuery(`(?s)FROM image_studio_jobs\s+WHERE id=\$1 AND user_id=\$2`). - WithArgs(int64(99), int64(7)). - WillReturnError(sql.ErrNoRows) - - _, err = NewImageStudioRepository(db).Get(context.Background(), 7, 99) - require.ErrorIs(t, err, service.ErrImageStudioNotFound) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestImageStudioRepositoryExpireRequestsPreservesSucceededItemCounts(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC) - - mock.ExpectBegin() - mock.ExpectExec(`UPDATE image_studio_items item`).WithArgs(now).WillReturnResult(sqlmock.NewResult(0, 2)) - mock.ExpectExec(`succeeded_count=counts\.succeeded`).WithArgs(now).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`UPDATE image_studio_jobs SET prompt=''`).WithArgs(now).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - - require.NoError(t, NewImageStudioRepository(db).ExpireRequests(context.Background(), now)) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func imageStudioJobRows(_ time.Time) *sqlmock.Rows { - return sqlmock.NewRows([]string{ - "id", "user_id", "api_key_id", "mode", "model", "prompt", "size", "quality", "count", "status", - "processed_count", "succeeded_count", "failed_count", "canceled_count", "cancel_requested_at", - "error_code", "error_message", "request_expires_at", "retain_until", "started_at", "finished_at", - "created_at", "updated_at", - }) -} diff --git a/backend/internal/repository/native_feature_bootstrap.go b/backend/internal/repository/native_feature_bootstrap.go index 28b57979b66b..1ec26f0fc932 100644 --- a/backend/internal/repository/native_feature_bootstrap.go +++ b/backend/internal/repository/native_feature_bootstrap.go @@ -21,7 +21,7 @@ func NewNativeFeatureBootstrapRepository(db *sql.DB) service.NativeFeatureBootst const nativeRetirementInstallationsSQL = `SELECT id,plugin_key,state,config_encrypted,runtime_generation,manifest FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.account-tools','codexrip.admin-observability','codexrip.cindy-provider', - 'codexrip.image-tools','codexrip.model-policy','codexrip.prompt-skills') + 'codexrip.model-policy','codexrip.prompt-skills') ORDER BY id FOR UPDATE` func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Context, convert func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error)) (*service.NativeRetirementSnapshot, error) { diff --git a/backend/internal/repository/native_feature_bootstrap_integration_test.go b/backend/internal/repository/native_feature_bootstrap_integration_test.go index fa77d040c327..a53de38d0a2e 100644 --- a/backend/internal/repository/native_feature_bootstrap_integration_test.go +++ b/backend/internal/repository/native_feature_bootstrap_integration_test.go @@ -15,12 +15,12 @@ import ( func TestNativeFeatureRetirementIsAtomic(t *testing.T) { ctx := context.Background() var present int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.image-tools')`).Scan(&present)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.admin-observability')`).Scan(&present)) require.Zero(t, present, "this fixture requires an isolated test database") t.Cleanup(func() { - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM settings WHERE key IN ('deplugin_retired_plugins','image_tools_config')`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.image-tools')`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM settings WHERE key IN ('deplugin_retired_plugins','admin_observability_config')`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.admin-observability'`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.admin-observability')`) }) insert := func(key string, generation int64) int64 { t.Helper() @@ -30,18 +30,18 @@ func TestNativeFeatureRetirementIsAtomic(t *testing.T) { VALUES($1,'retirement fixture','1.0.0','{}','','','','fixture','enabled','original encrypted config',$2,decode('010203','hex')) RETURNING id`, key, generation).Scan(&id)) return id } - imageID := insert("codexrip.image-tools", 3) + observabilityID := insert("codexrip.admin-observability", 3) policyID := insert("codexrip.model-policy", 9) - _, err := integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bindings(plugin_id,capability,platform,account_type,enabled,rollout_percent) VALUES($1,'extensions.request.v1','*','*',true,100)`, imageID) + _, err := integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bindings(plugin_id,capability,platform,account_type,enabled,rollout_percent) VALUES($1,'extensions.observability.v1','*','*',true,100)`, observabilityID) require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported) VALUES('codexrip.image-tools','fixture','image-tools-v1',true,true,true)`) + _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported) VALUES('codexrip.admin-observability','fixture','admin-observability-v1',true,true,true)`) require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('image_tools_config','{"studio_enabled":false}',NOW())`) + _, err = integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('admin_observability_config','{"telemetry_enabled":false,"theme_enabled":false}',NOW())`) require.NoError(t, err) repo := NewNativeFeatureBootstrapRepository(integrationDB) convert := func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - if plugin.Key == "codexrip.image-tools" { - return map[string]json.RawMessage{service.SettingKeyImageToolsConfig: json.RawMessage(`{"studio_enabled":true}`)}, nil + if plugin.Key == "codexrip.admin-observability" { + return map[string]json.RawMessage{service.SettingKeyAdminObservabilityConfig: json.RawMessage(`{"telemetry_enabled":true,"theme_enabled":true}`)}, nil } return nil, nil } @@ -53,7 +53,7 @@ func TestNativeFeatureRetirementIsAtomic(t *testing.T) { }) require.Error(t, err) var state string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state FROM sub2api_plugin_installations WHERE id=$1`, imageID).Scan(&state)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state FROM sub2api_plugin_installations WHERE id=$1`, observabilityID).Scan(&state)) require.Equal(t, "enabled", state, "an earlier disable must roll back with a later failed conversion") require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key='deplugin_retired_plugins'`).Scan(&present)) require.Zero(t, present) @@ -70,12 +70,12 @@ func TestNativeFeatureRetirementIsAtomic(t *testing.T) { require.Equal(t, "original encrypted config", cipher) require.Equal(t, "010203", artifact) var setting string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='image_tools_config'`).Scan(&setting)) - require.JSONEq(t, `{"studio_enabled":false}`, setting) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='admin_observability_config'`).Scan(&setting)) + require.JSONEq(t, `{"telemetry_enabled":false,"theme_enabled":false}`, setting) var bindingEnabled, removed bool - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT enabled FROM sub2api_plugin_bindings WHERE plugin_id=$1`, imageID).Scan(&bindingEnabled)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT enabled FROM sub2api_plugin_bindings WHERE plugin_id=$1`, observabilityID).Scan(&bindingEnabled)) require.False(t, bindingEnabled) - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`).Scan(&removed)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.admin-observability'`).Scan(&removed)) require.True(t, removed) second, err := repo.RetireNativeFeatures(ctx, func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { t.Fatal("a completed retirement must not repeat decryption or import") diff --git a/backend/internal/repository/native_policy_fixture_test.go b/backend/internal/repository/native_policy_fixture_test.go deleted file mode 100644 index eabf499e19c6..000000000000 --- a/backend/internal/repository/native_policy_fixture_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package repository - -import "github.com/Wei-Shaw/sub2api/internal/testextensions" - -func init() { testextensions.Install() } diff --git a/backend/internal/repository/ops_error_where_test.go b/backend/internal/repository/ops_error_where_test.go index 9179cf794b9f..af9af28de825 100644 --- a/backend/internal/repository/ops_error_where_test.go +++ b/backend/internal/repository/ops_error_where_test.go @@ -151,13 +151,3 @@ func TestBuildOpsErrorLogsWhere_UserOwnershipIsDirectOnly(t *testing.T) { t.Fatalf("user ownership must not depend on deleted-key attribution: %s", where) } } - -func TestBuildOpsErrorLogsWhere_ExcludesClientRequestIDPrefix(t *testing.T) { - where, args := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{ExcludeClientRequestIDPrefix: service.ImageStudioClientRequestIDPrefix}) - if !strings.Contains(where, "COALESCE(e.client_request_id,'') NOT LIKE $1") { - t.Fatalf("where missing the client request ID exclusion\nfull: %s", where) - } - if len(args) != 1 || args[0] != "image-studio-%" { - t.Fatalf("unexpected args: %v", args) - } -} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index c167593beaf0..11f427a00028 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -953,10 +953,6 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { args = append(args, crid) clauses = append(clauses, "COALESCE(e.client_request_id,'') = $"+itoa(len(args))) } - if prefix := strings.TrimSpace(filter.ExcludeClientRequestIDPrefix); prefix != "" { - args = append(args, escapeLikePattern(prefix)+"%") - clauses = append(clauses, "COALESCE(e.client_request_id,'') NOT LIKE $"+itoa(len(args))) - } if q := strings.TrimSpace(filter.Query); q != "" { like := "%" + q + "%" diff --git a/backend/internal/repository/postgres_driver.go b/backend/internal/repository/postgres_driver.go index 49040b235bf9..bffd510d6532 100644 --- a/backend/internal/repository/postgres_driver.go +++ b/backend/internal/repository/postgres_driver.go @@ -7,11 +7,7 @@ import ( "github.com/jackc/pgx/v5/stdlib" ) -// OpenPostgresDB opens a database/sql pool backed by pgx's PostgreSQL driver. -func OpenPostgresDB(dsn string) (*sql.DB, error) { - return openPostgresDB(dsn, false) -} - +// openPostgresDB opens a database/sql pool backed by pgx's PostgreSQL driver. func openPostgresDB(dsn string, enableServerTiming bool) (*sql.DB, error) { config, err := pgx.ParseConfig(dsn) if err != nil { diff --git a/backend/internal/repository/usage_billing_dedup_account_id_drop_migration_integration_test.go b/backend/internal/repository/usage_billing_dedup_account_id_drop_migration_integration_test.go new file mode 100644 index 000000000000..d458c219eec3 --- /dev/null +++ b/backend/internal/repository/usage_billing_dedup_account_id_drop_migration_integration_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +// TestMain applied every migration, 271 included, to an empty database: the +// fresh-database run. The test puts the column back as 238 added it, stores +// billing claims with and without an account next to an archived claim, +// applies 271 twice and checks that only the column went. +func TestMigration271DropsUsageBillingDedupAccountID(t *testing.T) { + ctx := context.Background() + migration, err := dbmigrations.FS.ReadFile("271_drop_usage_billing_dedup_account_id.sql") + require.NoError(t, err) + + tx := testTx(t) + exec := func(query string) { + t.Helper() + _, err := tx.ExecContext(ctx, query) + require.NoError(t, err) + } + text := func(query string) string { + t.Helper() + var value string + require.NoError(t, tx.QueryRowContext(ctx, query).Scan(&value)) + return value + } + columns := func(table string) string { + t.Helper() + return text(`SELECT string_agg(column_name, ',' ORDER BY ordinal_position) FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = '` + table + `'`) + } + const claimColumns = "id,request_id,api_key_id,request_fingerprint,created_at" + require.Equal(t, claimColumns, columns("usage_billing_dedup"), "271 already dropped the column 238 added") + + exec(`ALTER TABLE usage_billing_dedup ADD COLUMN account_id BIGINT`) + exec(`INSERT INTO usage_billing_dedup (request_id, api_key_id, request_fingerprint, created_at, account_id) VALUES + ('migration-271-with-account', 9100271, repeat('a', 64), '2026-09-01T00:00:00Z', 42), + ('migration-271-without-account', 9100271, repeat('b', 64), '2026-09-02T00:00:00Z', NULL)`) + exec(`INSERT INTO usage_billing_dedup_archive (request_id, api_key_id, request_fingerprint, created_at, archived_at) VALUES + ('migration-271-archived', 9100271, repeat('c', 64), '2025-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`) + + // The rows, row versions and indexes 271 must leave alone. + kept := func() map[string]string { + return map[string]string{ + "claims": text(`SELECT json_agg(json_build_array(ctid::text, id, request_id, api_key_id, request_fingerprint, created_at) ORDER BY id)::text + FROM usage_billing_dedup WHERE api_key_id = 9100271`), + "claim count": text(`SELECT count(*)::text FROM usage_billing_dedup`), + "archive": text(`SELECT json_agg(json_build_array(ctid::text, request_id, api_key_id, request_fingerprint, created_at, archived_at))::text + FROM usage_billing_dedup_archive WHERE api_key_id = 9100271`), + "archive columns": columns("usage_billing_dedup_archive"), + "indexes": text(`SELECT string_agg(indexdef, ';' ORDER BY indexname) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'usage_billing_dedup'`), + } + } + before := kept() + require.Contains(t, before["claims"], "migration-271-with-account") + require.Contains(t, before["claims"], "migration-271-without-account") + require.Contains(t, before["indexes"], "idx_usage_billing_dedup_request_api_key") + require.Contains(t, before["indexes"], "idx_usage_billing_dedup_created_at_brin") + + for run := 1; run <= 2; run++ { + exec(string(migration)) + require.Equal(t, claimColumns, columns("usage_billing_dedup"), "run %d", run) + require.Equal(t, before, kept(), "run %d changes nothing else", run) + } +} diff --git a/backend/internal/repository/usage_billing_owner_test.go b/backend/internal/repository/usage_billing_owner_test.go deleted file mode 100644 index c0c843964fa8..000000000000 --- a/backend/internal/repository/usage_billing_owner_test.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build unit - -package repository - -import ( - "context" - "regexp" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestUsageBillingApplyStoresFirstSuccessfulAccountOwner(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectBegin() - expectInsert := mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO usage_billing_dedup")) - expectInsert.WithArgs("req-1", int64(7), sqlmock.AnyArg(), int64(42)) - expectInsert.WillReturnRows(sqlmock.NewRows([]string{"id", "account_id"}).AddRow(1, 42)) - expectArchive := mock.ExpectQuery(regexp.QuoteMeta("SELECT request_fingerprint")) - expectArchive.WillReturnRows(sqlmock.NewRows([]string{"request_fingerprint"})) - expectBalance := mock.ExpectQuery(regexp.QuoteMeta("UPDATE users")) - expectBalance.WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(99.0)) - mock.ExpectCommit() - - repo := &usageBillingRepository{db: db} - result, err := repo.Apply(context.Background(), &service.UsageBillingCommand{ - RequestID: "req-1", APIKeyID: 7, UserID: 9, AccountID: 42, - RequestFingerprint: "fingerprint", BalanceCost: 1, - }) - require.NoError(t, err) - require.True(t, result.Applied) - require.Equal(t, int64(42), result.OwnerAccountID) - require.NoError(t, mock.ExpectationsWereMet()) -} diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index 22c323d055f6..bfb1bb0de5f4 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -42,15 +42,15 @@ func (r *usageBillingRepository) Apply(ctx context.Context, cmd *service.UsageBi } }() - applied, ownerAccountID, err := r.claimUsageBillingKeyWithOwner(ctx, tx, cmd) + applied, err := r.claimUsageBillingKey(ctx, tx, cmd) if err != nil { return nil, err } if !applied { - return &service.UsageBillingApplyResult{Applied: false, OwnerAccountID: ownerAccountID}, nil + return &service.UsageBillingApplyResult{Applied: false}, nil } - result := &service.UsageBillingApplyResult{Applied: true, OwnerAccountID: ownerAccountID} + result := &service.UsageBillingApplyResult{Applied: true} if err := r.applyUsageBillingEffects(ctx, tx, cmd, result); err != nil { return nil, err } @@ -62,40 +62,34 @@ func (r *usageBillingRepository) Apply(ctx context.Context, cmd *service.UsageBi return result, nil } -func (r *usageBillingRepository) claimUsageBillingKeyWithOwner(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand) (bool, int64, error) { - return r.claimUsageBillingRequestWithOwner(ctx, tx, cmd.RequestID, cmd.APIKeyID, cmd.RequestFingerprint, cmd.AccountID) +func (r *usageBillingRepository) claimUsageBillingKey(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand) (bool, error) { + return r.claimUsageBillingRequest(ctx, tx, cmd.RequestID, cmd.APIKeyID, cmd.RequestFingerprint) } func (r *usageBillingRepository) claimUsageBillingRequest(ctx context.Context, tx *sql.Tx, requestID string, apiKeyID int64, requestFingerprint string) (bool, error) { - applied, _, err := r.claimUsageBillingRequestWithOwner(ctx, tx, requestID, apiKeyID, requestFingerprint, 0) - return applied, err -} - -func (r *usageBillingRepository) claimUsageBillingRequestWithOwner(ctx context.Context, tx *sql.Tx, requestID string, apiKeyID int64, requestFingerprint string, accountID int64) (bool, int64, error) { - var ownerAccountID int64 var id int64 err := tx.QueryRowContext(ctx, ` - INSERT INTO usage_billing_dedup (request_id, api_key_id, request_fingerprint, account_id) - VALUES ($1, $2, $3, NULLIF($4, 0)) + INSERT INTO usage_billing_dedup (request_id, api_key_id, request_fingerprint) + VALUES ($1, $2, $3) ON CONFLICT (request_id, api_key_id) DO NOTHING - RETURNING id, COALESCE(account_id, 0) - `, requestID, apiKeyID, requestFingerprint, accountID).Scan(&id, &ownerAccountID) + RETURNING id + `, requestID, apiKeyID, requestFingerprint).Scan(&id) if errors.Is(err, sql.ErrNoRows) { var existingFingerprint string if err := tx.QueryRowContext(ctx, ` - SELECT request_fingerprint, COALESCE(account_id, 0) + SELECT request_fingerprint FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2 - `, requestID, apiKeyID).Scan(&existingFingerprint, &ownerAccountID); err != nil { - return false, 0, err + `, requestID, apiKeyID).Scan(&existingFingerprint); err != nil { + return false, err } if strings.TrimSpace(existingFingerprint) != strings.TrimSpace(requestFingerprint) { - return false, ownerAccountID, service.ErrUsageBillingRequestConflict + return false, service.ErrUsageBillingRequestConflict } - return false, ownerAccountID, nil + return false, nil } if err != nil { - return false, 0, err + return false, err } var archivedFingerprint string err = tx.QueryRowContext(ctx, ` @@ -105,14 +99,14 @@ func (r *usageBillingRepository) claimUsageBillingRequestWithOwner(ctx context.C `, requestID, apiKeyID).Scan(&archivedFingerprint) if err == nil { if strings.TrimSpace(archivedFingerprint) != strings.TrimSpace(requestFingerprint) { - return false, ownerAccountID, service.ErrUsageBillingRequestConflict + return false, service.ErrUsageBillingRequestConflict } - return false, ownerAccountID, nil + return false, nil } if !errors.Is(err, sql.ErrNoRows) { - return false, 0, err + return false, err } - return true, ownerAccountID, nil + return true, nil } func (r *usageBillingRepository) ReserveBatchImageBalance(ctx context.Context, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) { diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 6d6512f346d1..c2426000e1da 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -72,7 +72,6 @@ var ProviderSet = wire.NewSet( NewCompositeModelRouteRepository, NewAccountRepository, NewAccountJobRepository, - NewImageStudioRepository, NewAdminAccountRepository, NewScheduledTestPlanRepository, // 定时测试计划仓储 NewScheduledTestResultRepository, // 定时测试结果仓储 diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5d10b68e0a9c..5eb952928c01 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -627,8 +627,6 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpA adminSettings.GET("", h.Admin.Setting.GetSettings) adminSettings.PUT("", h.Admin.Setting.UpdateSettings) adminSettings.GET("/model-context-catalog", h.Admin.Setting.GetOfficialModelContextCatalog) - adminSettings.GET("/image-tools", h.Admin.Setting.GetImageToolsSettings) - adminSettings.PUT("/image-tools", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateImageToolsSettings) adminSettings.GET("/observability", h.Admin.Setting.GetObservabilitySettings) adminSettings.PUT("/observability", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateObservabilitySettings) adminSettings.GET("/codex-runtime", h.Admin.Setting.GetCodexRuntimeSettings) diff --git a/backend/internal/server/routes/gateway_models_pinned_test.go b/backend/internal/server/routes/gateway_models_pinned_test.go index 76e8beb741d4..a0b2cc655675 100644 --- a/backend/internal/server/routes/gateway_models_pinned_test.go +++ b/backend/internal/server/routes/gateway_models_pinned_test.go @@ -14,7 +14,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler" servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/Wei-Shaw/sub2api/internal/testextensions" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -89,8 +88,6 @@ func (u *pinnedModelsRoutesUpstream) Do(req *http.Request, _ string, _ int64, _ func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.T) { gin.SetMode(gin.TestMode) - testextensions.Install() - t.Cleanup(func() { service.ConfigureNativePolicyOperations(nil) }) repo := &pinnedModelsRoutesRepository{account: service.Account{ ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 3e65b93fd53f..65ecaa037d8c 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -95,18 +95,6 @@ func RegisterUserRoutes( channels.GET("/available", h.AvailableChannel.List) } - imageStudio := authenticated.Group("/image-studio") - { - imageStudio.GET("/eligible-keys", h.ImageStudio.EligibleKeys) - imageStudio.POST("/jobs", h.ImageStudio.Create) - imageStudio.GET("/jobs", h.ImageStudio.List) - imageStudio.GET("/jobs/:id", h.ImageStudio.Get) - imageStudio.GET("/jobs/:id/items", h.ImageStudio.Items) - imageStudio.POST("/jobs/:id/cancel", h.ImageStudio.Cancel) - imageStudio.POST("/jobs/:id/retry", h.ImageStudio.Retry) - imageStudio.GET("/jobs/:id/artifacts/:artifact_id", h.ImageStudio.Artifact) - } - // 使用记录(聚合统计属重查询,叠加更严格的按用户限流) usage := authenticated.Group("/usage") usage.Use(panelRateLimiter.Heavy()) diff --git a/backend/internal/service/account_business_errors.go b/backend/internal/service/account_business_errors.go index 98c2412d063e..dbeb16737fa7 100644 --- a/backend/internal/service/account_business_errors.go +++ b/backend/internal/service/account_business_errors.go @@ -1,9 +1,6 @@ package service -import ( - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "strings" -) +import "strings" const ( AccountJobCodePayloadExpired = "payload_expired" @@ -13,11 +10,11 @@ const ( AccountJobCodeCompletionFailed = "item_completion_failed" AccountJobCodeResultRedacted = "result_redacted" AccountJobCodeExecutionFailed = "execution_failed" - AccountImportCodeCreate = extensionv1.AccountImportCodeCreate - AccountImportCodeUpdate = extensionv1.AccountImportCodeUpdate - AccountImportCodePayloadInvalid = extensionv1.AccountImportCodePayloadInvalid - AccountImportCodeIdentityConflict = extensionv1.AccountImportCodeIdentityConflict - AccountImportCodeExecutionFailed = extensionv1.AccountImportCodeExecutionFailed + AccountImportCodeCreate = "account_import_create" + AccountImportCodeUpdate = "account_import_update" + AccountImportCodePayloadInvalid = "account_import_payload_invalid" + AccountImportCodeIdentityConflict = "account_import_identity_conflict" + AccountImportCodeExecutionFailed = "account_import_execution_failed" ) type accountBusinessMessage struct { diff --git a/backend/internal/service/account_import_policy.go b/backend/internal/service/account_import_policy.go deleted file mode 100644 index fcabe97af026..000000000000 --- a/backend/internal/service/account_import_policy.go +++ /dev/null @@ -1,98 +0,0 @@ -package service - -import ( - "context" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func PlanAccountImport(ctx context.Context, in extensionv1.AccountImportPlanningRequest) ([]extensionv1.AccountImportItemPlan, error) { - in.Phase, in.Prepared = "", nil - plans, err := collectAccountImportPlans(ctx, in) - if err != nil { - return nil, err - } - if len(plans) != len(in.Items) { - return nil, ErrExtensionOperationUnavailable - } - for index, plan := range plans { - facts := in.Items[index] - if plan.Index != index { - return nil, ErrExtensionOperationUnavailable - } - switch plan.Action { - case "create": - if plan.AccountID != 0 || len(facts.Matches) != 0 || plan.Code != extensionv1.AccountImportCodeCreate { - return nil, ErrExtensionOperationUnavailable - } - case "update": - if plan.AccountID <= 0 || len(facts.Matches) != 1 || facts.Matches[0] != plan.AccountID || plan.Code != extensionv1.AccountImportCodeUpdate { - return nil, ErrExtensionOperationUnavailable - } - case "reject": - if plan.AccountID != 0 || (plan.Code != extensionv1.AccountImportCodePayloadInvalid && plan.Code != extensionv1.AccountImportCodeIdentityConflict) { - return nil, ErrExtensionOperationUnavailable - } - default: - return nil, ErrExtensionOperationUnavailable - } - if plan.Action != "reject" && !facts.PayloadValid { - return nil, ErrExtensionOperationUnavailable - } - } - return plans, nil -} - -// Large import files still fit the official bounded RPC protocol: they are -// planned in chunks, then each chunk's prepared plans are finalized. -func collectAccountImportPlans(ctx context.Context, in extensionv1.AccountImportPlanningRequest) ([]extensionv1.AccountImportItemPlan, error) { - const chunkSize = 1000 - if len(in.Items) <= chunkSize { - var plans []extensionv1.AccountImportItemPlan - err := accountToolsOperation(ctx, "import.plan", in, &plans) - return plans, err - } - prepared := make([]extensionv1.AccountImportItemPlan, 0, len(in.Items)) - for start := 0; start < len(in.Items); start += chunkSize { - end := min(start+chunkSize, len(in.Items)) - chunk := in - chunk.Phase = "prepare" - chunk.Items = in.Items[start:end] - var plans []extensionv1.AccountImportItemPlan - if err := accountToolsOperation(ctx, "import.plan", chunk, &plans); err != nil { - return nil, err - } - if len(plans) != len(chunk.Items) { - return nil, ErrExtensionOperationUnavailable - } - for index, plan := range plans { - if plan.Index != index { - return nil, ErrExtensionOperationUnavailable - } - } - prepared = append(prepared, plans...) - } - result := make([]extensionv1.AccountImportItemPlan, 0, len(in.Items)) - for start := 0; start < len(in.Items); start += chunkSize { - end := min(start+chunkSize, len(in.Items)) - chunk := in - chunk.Phase = "finalize" - chunk.Prepared = prepared[start:end] - chunk.Items = in.Items[start:end] - var plans []extensionv1.AccountImportItemPlan - if err := accountToolsOperation(ctx, "import.plan", chunk, &plans); err != nil { - return nil, err - } - if len(plans) != len(chunk.Items) { - return nil, ErrExtensionOperationUnavailable - } - for index := range plans { - if plans[index].Index != index { - return nil, ErrExtensionOperationUnavailable - } - plans[index].Index += start - } - result = append(result, plans...) - } - return result, nil -} diff --git a/backend/internal/service/account_import_policy_test.go b/backend/internal/service/account_import_policy_test.go deleted file mode 100644 index 649e98a86861..000000000000 --- a/backend/internal/service/account_import_policy_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -type importPlanFixture struct { - payloadSizes []int - corrupt bool -} - -func (f *importPlanFixture) invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation == "import.plan" { - f.payloadSizes = append(f.payloadSizes, len(in.Payload)) - if f.corrupt { - raw, _ := json.Marshal([]extensionv1.AccountImportItemPlan{{Index: 0, Action: "update", Code: extensionv1.AccountImportCodeUpdate, AccountID: 999}}) - return extensionv1.Result{Payload: raw}, nil - } - } - return accountTools().Invoke(ctx, in) -} - -func TestAccountImportPlanChunksLargeImportsAndRejectsForgedPlans(t *testing.T) { - fixture := &importPlanFixture{} - replaceAccountTools(t, fixture.invoke) - input := extensionv1.AccountImportPlanningRequest{Items: make([]extensionv1.AccountImportItemFacts, 1002)} - for index := range input.Items { - input.Items[index].PayloadValid = true - } - input.Items[0].Matches = []int64{38, 39} - input.Items[10].Matches = []int64{37} - input.Items[1001].PayloadValid = false - plans, err := PlanAccountImport(context.Background(), input) - require.NoError(t, err) - require.Len(t, plans, len(input.Items)) - require.Equal(t, "reject", plans[0].Action) - require.Equal(t, extensionv1.AccountImportCodeIdentityConflict, plans[0].Code) - require.Equal(t, "update", plans[10].Action) - require.EqualValues(t, 37, plans[10].AccountID) - require.Equal(t, "create", plans[20].Action) - require.Equal(t, 1001, plans[1001].Index) - require.Equal(t, extensionv1.AccountImportCodePayloadInvalid, plans[1001].Code) - require.Len(t, fixture.payloadSizes, 4, "two prepared chunks are finalized separately") - for _, size := range fixture.payloadSizes { - require.Less(t, size, extensionv1.MaxPayloadBytes) - } - fixture.corrupt = true - _, err = PlanAccountImport(context.Background(), extensionv1.AccountImportPlanningRequest{Items: []extensionv1.AccountImportItemFacts{{PayloadValid: true, Matches: []int64{37}}}}) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "a plan cannot choose another existing account") - replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, errors.New("policy failed") - }) - _, err = PlanAccountImport(context.Background(), input) - require.Error(t, err, "a failed policy must not fall back to a host decision") -} diff --git a/backend/internal/service/account_test_prompt.go b/backend/internal/service/account_test_prompt.go index 39b895f4c6bb..5ca22d09d361 100644 --- a/backend/internal/service/account_test_prompt.go +++ b/backend/internal/service/account_test_prompt.go @@ -1,12 +1,10 @@ package service import ( - "context" "errors" "strings" "unicode/utf8" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" ) @@ -47,13 +45,6 @@ func accountTestUsesTextPrompt(model, mode string) bool { return !strings.Contains(strings.ToLower(model), "image") } -func validateAccountPromptExtension(ctx context.Context, prompt, model, mode string) error { - if strings.TrimSpace(prompt) == "" || !accountTestUsesTextPrompt(model, mode) || mode == "search" { - return nil - } - return accountToolsOperation(ctx, "test.prompt", extensionv1.TextPromptSelection{Characters: utf8.RuneCountInString(prompt), ValidUTF8: utf8.ValidString(prompt)}, nil) -} - func ValidateAccountTestPrompt(prompt string) error { if !utf8.ValidString(prompt) || utf8.RuneCountInString(prompt) > AccountTestPromptMaxCharacters { return errors.New("test prompt must be valid UTF-8 and at most 8192 characters") diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go index 393090800ade..7c8c4adfe46d 100644 --- a/backend/internal/service/account_test_reasoning.go +++ b/backend/internal/service/account_test_reasoning.go @@ -1,12 +1,11 @@ package service import ( - "context" "encoding/json" "errors" + "slices" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -104,15 +103,20 @@ func accountTestSupportsReasoningWire(account *Account, model string) bool { } } -func ValidateAccountTestReasoningContext(ctx context.Context, account *Account, model, mode, effort string) error { +// ValidateAccountTestReasoning accepts a reasoning effort only in the text +// test modes and only when the tested account model offers it. +func ValidateAccountTestReasoning(account *Account, model, mode, effort string) error { if effort == "" { return nil } - if account == nil { - return errors.New("account is unavailable") + if effort != strings.TrimSpace(effort) || len(effort) > 32 || (mode != "" && mode != AccountTestModeDefault && mode != AccountTestModeGrokText) { + return errors.New("reasoning effort is unsupported for this test mode") } levels, _ := AccountTestReasoningOptions(account, model) - return accountToolsOperation(ctx, "test.reasoning", extensionv1.ReasoningSelection{Mode: mode, Effort: effort, Levels: levels}, nil) + if !slices.Contains(levels, effort) { + return errors.New("reasoning effort is not supported by the selected account model") + } + return nil } func applyAccountTestReasoning(c *gin.Context, payload map[string]any, chat bool) { diff --git a/backend/internal/service/account_test_reasoning_test.go b/backend/internal/service/account_test_reasoning_test.go index 639d419f982d..6b5451f4efa9 100644 --- a/backend/internal/service/account_test_reasoning_test.go +++ b/backend/internal/service/account_test_reasoning_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "encoding/json" "net/http/httptest" "testing" @@ -15,11 +14,13 @@ func TestAccountTestReasoningValidatedAgainstMappedModel(t *testing.T) { a := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} levels, _ := AccountTestReasoningOptions(a, "friendly") require.Contains(t, levels, "ultra") - require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "default", "ultra")) - require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "compact", "ultra")) - require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "friendly", "default", "invented")) - require.Error(t, ValidateAccountTestReasoningContext(context.Background(), a, "gpt-image-2", "default", "high")) - require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), a, "unknown-model", "default", "")) + require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "ultra")) + require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "text", "ultra")) + require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "compact", "ultra"), "reasoning effort is unsupported for this test mode") + require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", " ultra "), "reasoning effort is unsupported for this test mode") + require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", "invented"), "reasoning effort is not supported by the selected account model") + require.Error(t, ValidateAccountTestReasoning(a, "gpt-image-2", "default", "high")) + require.NoError(t, ValidateAccountTestReasoning(a, "unknown-model", "default", "")) } func TestAccountTestReasoningFinalPayload(t *testing.T) { @@ -47,8 +48,8 @@ func TestAccountTestReasoningDoesNotAdvertiseUnsupportedProtocol(t *testing.T) { account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"test-model": {Reasoning: &supported, SupportedReasoningLevels: []string{"high"}}}}) levels, _ := AccountTestReasoningOptions(account, "test-model") require.Empty(t, levels) - require.Error(t, ValidateAccountTestReasoningContext(context.Background(), account, "test-model", "default", "high")) - require.NoError(t, ValidateAccountTestReasoningContext(context.Background(), account, "test-model", "default", "")) + require.Error(t, ValidateAccountTestReasoning(account, "test-model", "default", "high")) + require.NoError(t, ValidateAccountTestReasoning(account, "test-model", "default", "")) } } diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go index 57f83f394783..f35dedb034c9 100644 --- a/backend/internal/service/account_test_reasoning_wire_test.go +++ b/backend/internal/service/account_test_reasoning_wire_test.go @@ -35,9 +35,14 @@ func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) { require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String()) require.Equal(t, "ultra", gjson.GetBytes(body, "reasoning.effort").String()) require.Contains(t, rec.Body.String(), `"effective_reasoning_effort":"ultra"`) - c, _ = newTestContext() + c, rec = newTestContext() require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeCompact, AccountTestOptions{ReasoningEffort: "ultra"})) + require.Contains(t, rec.Body.String(), `"error":"reasoning effort is unsupported for this test mode"`) require.Len(t, upstream.requests, 1, "unsupported mode must fail before any upstream request") + c, rec = newTestContext() + require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "invented"})) + require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`) + require.Len(t, upstream.requests, 1, "unsupported effort must fail before any upstream request") } func TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol(t *testing.T) { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index ad205045261d..e812a3d1ea51 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -410,10 +410,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.sendErrorAndEnd(c, "Account not found") } c.Set("account_test_allow_media", !accountTestUsesTextPrompt(modelID, mode)) - if err := validateAccountPromptExtension(ctx, prompt, modelID, mode); err != nil { - return s.sendErrorAndEnd(c, err.Error()) - } - if err := ValidateAccountTestReasoningContext(ctx, account, modelID, mode, testOpts.ReasoningEffort); err != nil { + if err := ValidateAccountTestReasoning(account, modelID, mode, testOpts.ReasoningEffort); err != nil { return s.sendErrorAndEnd(c, err.Error()) } c.Set(accountTestReasoningContextKey, testOpts.ReasoningEffort) diff --git a/backend/internal/service/account_tools.go b/backend/internal/service/account_tools.go deleted file mode 100644 index f59ee5d51ddc..000000000000 --- a/backend/internal/service/account_tools.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" -) - -func accountToolsOperation(ctx context.Context, operation string, input, output any) error { - raw, err := json.Marshal(input) - if err != nil { - return err - } - call, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - result, err := invokeAccountTools(call, extensionv1.Invocation{Operation: operation, Payload: raw}) - if err != nil { - // Administrators see why the operation failed (for example a timeout). - return infraerrors.New(503, "ACCOUNT_TOOLS_UNAVAILABLE", "account tools are unavailable: "+err.Error()).WithCause(err) - } - if result.Code != "" { - status := result.HTTPStatus - if status < 400 || status > 499 { - status = 503 - } - return infraerrors.New(status, result.Code, result.Message) - } - if output != nil { - if err := json.Unmarshal(result.Payload, output); err != nil { - return err - } - } - return nil -} - -func ValidateAccountTaxonomyPlan(ctx context.Context, plan extensionv1.TaxonomyBulkPlan) error { - return accountToolsOperation(ctx, "taxonomy.bulk", plan, nil) -} - -func normalizeAccountTaxonomyNameContext(ctx context.Context, value string) (string, string, error) { - var normalized extensionv1.TaxonomyName - err := accountToolsOperation(ctx, "taxonomy.name", extensionv1.TaxonomyName{Name: value}, &normalized) - return normalized.Name, normalized.Normalized, err -} - -func validateTaxonomyOrderIDsContext(ctx context.Context, actual, ordered []int64) error { - return accountToolsOperation(ctx, "taxonomy.order", extensionv1.TaxonomyOrder{Actual: actual, Ordered: ordered}, nil) -} diff --git a/backend/internal/service/account_tools_module.go b/backend/internal/service/account_tools_module.go deleted file mode 100644 index e53dd9307df2..000000000000 --- a/backend/internal/service/account_tools_module.go +++ /dev/null @@ -1,19 +0,0 @@ -package service - -import ( - "context" - "sync" - - accounttoolspolicy "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// accountTools answers the account classification, test and import planning -// operations in process. The account-tools plugin served them before. -var accountTools = sync.OnceValue(accounttoolspolicy.New) - -// invokeAccountTools runs one account tools operation. Tests replace it to -// model rejected plans. -var invokeAccountTools = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - return accountTools().Invoke(ctx, in) -} diff --git a/backend/internal/service/admin_account_taxonomy.go b/backend/internal/service/admin_account_taxonomy.go index 8aed3dac8fc7..83b274033f81 100644 --- a/backend/internal/service/admin_account_taxonomy.go +++ b/backend/internal/service/admin_account_taxonomy.go @@ -2,10 +2,12 @@ package service import ( "context" + "slices" "sort" "strconv" "strings" "time" + "unicode/utf8" dbent "github.com/Wei-Shaw/sub2api/ent" dbaccount "github.com/Wei-Shaw/sub2api/ent/account" @@ -14,7 +16,6 @@ import ( dbaccounttag "github.com/Wei-Shaw/sub2api/ent/accounttag" dbaccounttagbinding "github.com/Wei-Shaw/sub2api/ent/accounttagbinding" "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" entsql "entgo.io/ent/dialect/sql" @@ -39,13 +40,11 @@ type AccountTaxonomyAssignment struct { } type BulkAccountTaxonomyInput struct { - AccountIDs []int64 - Filters *BulkUpdateAccountFilters - ExpectedMatchCount *int - FolderAction string - FolderID *int64 - TagAddIDs []int64 - TagRemoveIDs []int64 + AccountIDs []int64 + FolderAction string + FolderID *int64 + TagAddIDs []int64 + TagRemoveIDs []int64 } type BulkAccountTaxonomyResult struct { @@ -89,6 +88,16 @@ type AccountConsoleFacets struct { Tags []AccountManagementTag `json:"tags"` } +// normalizeAccountTaxonomyName returns a folder or tag name as displayed and +// the case-insensitive key that keeps names unique. +func normalizeAccountTaxonomyName(value string) (string, string, error) { + name := strings.TrimSpace(value) + if !utf8.ValidString(name) || name == "" || utf8.RuneCountInString(name) > 100 || strings.ContainsRune(name, '\x00') { + return "", "", infraerrors.BadRequest("ACCOUNT_TAXONOMY_NAME_INVALID", "name must contain between 1 and 100 characters") + } + return name, strings.ToLower(name), nil +} + func (s *adminServiceImpl) ListAccountFolders(ctx context.Context) ([]AccountManagementFolder, error) { return s.listAccountFolders(ctx, true) } @@ -132,7 +141,7 @@ func (s *adminServiceImpl) listAccountFolders(ctx context.Context, includeCounts } func (s *adminServiceImpl) CreateAccountFolder(ctx context.Context, input AccountTaxonomyInput) (*AccountManagementFolder, error) { - name, normalized, err := normalizeAccountTaxonomyNameContext(ctx, input.Name) + name, normalized, err := normalizeAccountTaxonomyName(input.Name) if err != nil { return nil, err } @@ -159,7 +168,7 @@ func (s *adminServiceImpl) CreateAccountFolder(ctx context.Context, input Accoun } func (s *adminServiceImpl) UpdateAccountFolder(ctx context.Context, id int64, input AccountTaxonomyInput) (*AccountManagementFolder, error) { - name, normalized, err := normalizeAccountTaxonomyNameContext(ctx, input.Name) + name, normalized, err := normalizeAccountTaxonomyName(input.Name) if err != nil { return nil, err } @@ -182,8 +191,8 @@ func (s *adminServiceImpl) UpdateAccountFolder(ctx context.Context, id int64, in } func (s *adminServiceImpl) DeleteAccountFolder(ctx context.Context, id int64, moveAccounts bool) error { - if err := accountToolsOperation(ctx, "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { - return err + if id <= 0 { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_ID_INVALID", "ID must be positive") } tx, err := s.entClient.Tx(ctx) if err != nil { @@ -269,7 +278,7 @@ func (s *adminServiceImpl) listAccountTags(ctx context.Context, includeCounts bo } func (s *adminServiceImpl) CreateAccountTag(ctx context.Context, input AccountTaxonomyInput) (*AccountManagementTag, error) { - name, normalized, err := normalizeAccountTaxonomyNameContext(ctx, input.Name) + name, normalized, err := normalizeAccountTaxonomyName(input.Name) if err != nil { return nil, err } @@ -296,7 +305,7 @@ func (s *adminServiceImpl) CreateAccountTag(ctx context.Context, input AccountTa } func (s *adminServiceImpl) UpdateAccountTag(ctx context.Context, id int64, input AccountTaxonomyInput) (*AccountManagementTag, error) { - name, normalized, err := normalizeAccountTaxonomyNameContext(ctx, input.Name) + name, normalized, err := normalizeAccountTaxonomyName(input.Name) if err != nil { return nil, err } @@ -319,8 +328,8 @@ func (s *adminServiceImpl) UpdateAccountTag(ctx context.Context, id int64, input } func (s *adminServiceImpl) DeleteAccountTag(ctx context.Context, id int64) error { - if err := accountToolsOperation(ctx, "taxonomy.delete", map[string]int64{"id": id}, nil); err != nil { - return err + if id <= 0 { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_ID_INVALID", "ID must be positive") } err := s.entClient.AccountTag.DeleteOneID(id).Exec(ctx) if dbent.IsNotFound(err) { @@ -329,6 +338,23 @@ func (s *adminServiceImpl) DeleteAccountTag(ctx context.Context, id int64) error return err } +// validateTaxonomyOrderIDs accepts an order only when it lists every existing +// folder or tag exactly once. +func validateTaxonomyOrderIDs(actual, ordered []int64) error { + if len(actual) != len(ordered) { + return infraerrors.Conflict("ACCOUNT_TAXONOMY_ORDER_CHANGED", "account taxonomy changed; reload and try again") + } + if !positiveUniqueIDs(ordered) { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_ORDER_INVALID", "ordered_ids must contain positive unique IDs") + } + for _, id := range actual { + if !slices.Contains(ordered, id) { + return infraerrors.Conflict("ACCOUNT_TAXONOMY_ORDER_CHANGED", "account taxonomy changed; reload and try again") + } + } + return nil +} + func (s *adminServiceImpl) ReorderAccountFolders(ctx context.Context, orderedIDs []int64) ([]AccountManagementFolder, error) { tx, err := s.entClient.Tx(ctx) if err != nil { @@ -343,7 +369,7 @@ func (s *adminServiceImpl) ReorderAccountFolders(ctx context.Context, orderedIDs for _, row := range rows { actual = append(actual, row.ID) } - if err = validateTaxonomyOrderIDsContext(ctx, actual, orderedIDs); err != nil { + if err = validateTaxonomyOrderIDs(actual, orderedIDs); err != nil { return nil, err } for index, id := range orderedIDs { @@ -371,7 +397,7 @@ func (s *adminServiceImpl) ReorderAccountTags(ctx context.Context, orderedIDs [] for _, row := range rows { actual = append(actual, row.ID) } - if err = validateTaxonomyOrderIDsContext(ctx, actual, orderedIDs); err != nil { + if err = validateTaxonomyOrderIDs(actual, orderedIDs); err != nil { return nil, err } for index, id := range orderedIDs { @@ -401,36 +427,60 @@ func uniquePositiveIDs(ids []int64) []int64 { return out } -func (s *adminServiceImpl) BulkUpdateAccountTaxonomy(ctx context.Context, input BulkAccountTaxonomyInput) (*BulkAccountTaxonomyResult, error) { - plan := extensionv1.TaxonomyBulkPlan{AccountIDs: input.AccountIDs, HasFilters: input.Filters != nil, ExpectedMatchCount: input.ExpectedMatchCount, FolderAction: input.FolderAction, FolderID: input.FolderID, TagAddIDs: input.TagAddIDs, TagRemoveIDs: input.TagRemoveIDs} - if err := accountToolsOperation(ctx, "taxonomy.bulk", plan, nil); err != nil { - return nil, err +// positiveUniqueIDs reports whether every ID is positive and listed once. +func positiveUniqueIDs(ids []int64) bool { + return len(uniquePositiveIDs(ids)) == len(ids) +} + +// ValidateBulkAccountTaxonomy checks a bulk folder and tag change before any +// account is read. The admin handler also runs it before queuing the change, +// so a malformed request fails at once instead of in every queued account. +func ValidateBulkAccountTaxonomy(input BulkAccountTaxonomyInput) error { + if len(input.AccountIDs) == 0 { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_TARGET_INVALID", "account_ids must not be empty") } - filteredTarget := input.Filters != nil - var err error - if filteredTarget { - if input.ExpectedMatchCount == nil || *input.ExpectedMatchCount < 0 { - return nil, infraerrors.BadRequest("ACCOUNT_TAXONOMY_EXPECTED_COUNT_REQUIRED", "expected_match_count is required for filter targets") + if !positiveUniqueIDs(input.AccountIDs) { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_ACCOUNT_IDS_INVALID", "IDs must be positive and unique") + } + if !positiveUniqueIDs(input.TagAddIDs) || !positiveUniqueIDs(input.TagRemoveIDs) { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_TAG_IDS_INVALID", "IDs must be positive and unique") + } + for _, id := range input.TagAddIDs { + if slices.Contains(input.TagRemoveIDs, id) { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_TAG_OPERATION_CONFLICT", "a tag cannot be added and removed in the same request") } - input.AccountIDs, err = s.resolveBulkUpdateTargetIDs(ctx, input.Filters) - if err != nil { - return nil, err + } + switch input.FolderAction { + case "": + if input.FolderID != nil { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_id requires folder_action=set") } - input.AccountIDs = uniquePositiveIDs(input.AccountIDs) - if len(input.AccountIDs) != *input.ExpectedMatchCount { - return nil, infraerrors.Conflict("ACCOUNT_TAXONOMY_TARGET_CHANGED", "matching accounts changed; reload and confirm again").WithMetadata(map[string]string{ - "expected_match_count": strconv.Itoa(*input.ExpectedMatchCount), - "actual_match_count": strconv.Itoa(len(input.AccountIDs)), - }) + case "set": + if input.FolderID == nil || *input.FolderID <= 0 { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_FOLDER_ID_INVALID", "folder_id must be positive when folder_action=set") } + case "clear": + if input.FolderID != nil { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_id must be omitted when folder_action=clear") + } + default: + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_FOLDER_ACTION_INVALID", "folder_action must be set, clear, or omitted") } - if len(input.AccountIDs) == 0 { - return &BulkAccountTaxonomyResult{}, nil + if input.FolderAction == "" && len(input.TagAddIDs) == 0 && len(input.TagRemoveIDs) == 0 { + return infraerrors.BadRequest("ACCOUNT_TAXONOMY_OPERATION_REQUIRED", "at least one taxonomy operation is required") + } + return nil +} + +func (s *adminServiceImpl) BulkUpdateAccountTaxonomy(ctx context.Context, input BulkAccountTaxonomyInput) (*BulkAccountTaxonomyResult, error) { + if err := ValidateBulkAccountTaxonomy(input); err != nil { + return nil, err } contextTx := dbent.TxFromContext(ctx) var txClient *dbent.Client var ownedTx *dbent.Tx + var err error if contextTx != nil { txClient = contextTx.Client() } else { @@ -449,9 +499,6 @@ func (s *adminServiceImpl) BulkUpdateAccountTaxonomy(ctx context.Context, input return nil, err } if len(lockedAccounts) != len(input.AccountIDs) { - if filteredTarget { - return nil, infraerrors.Conflict("ACCOUNT_TAXONOMY_TARGET_CHANGED", "matching accounts changed; reload and confirm again") - } return nil, ErrAccountNotFound } if input.FolderAction == "set" { @@ -595,14 +642,15 @@ func (s *adminServiceImpl) hydrateAccountTaxonomy(ctx context.Context, accounts } func (s *adminServiceImpl) SetAccountTaxonomy(ctx context.Context, accountID int64, assignment AccountTaxonomyAssignment) (*Account, error) { - var plan extensionv1.TaxonomyAssignmentPlan - if err := accountToolsOperation(ctx, "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: assignment.FolderID, TagIDs: assignment.TagIDs}, &plan); err != nil { - return nil, err + if assignment.FolderID != nil && *assignment.FolderID <= 0 { + return nil, infraerrors.BadRequest("ACCOUNT_FOLDER_ID_INVALID", "folder_id must be positive or null") } - if err := validateTaxonomyAssignmentIntent(assignment, plan); err != nil { - return nil, err + for _, id := range assignment.TagIDs { + if id <= 0 { + return nil, infraerrors.BadRequest("ACCOUNT_TAG_ID_INVALID", "tag_ids must contain positive IDs") + } } - assignment.FolderID, assignment.TagIDs = plan.FolderID, plan.TagIDs + assignment.TagIDs = uniquePositiveIDs(assignment.TagIDs) contextTx := dbent.TxFromContext(ctx) var txClient *dbent.Client var ownedTx *dbent.Tx @@ -623,9 +671,6 @@ func (s *adminServiceImpl) SetAccountTaxonomy(ctx context.Context, accountID int return nil, ErrAccountNotFound } if assignment.FolderID != nil { - if *assignment.FolderID <= 0 { - return nil, infraerrors.BadRequest("ACCOUNT_FOLDER_ID_INVALID", "folder_id must be positive or null") - } if exists, queryErr := txClient.AccountFolder.Query().Where(dbaccountfolder.IDEQ(*assignment.FolderID)).Exist(ctx); queryErr != nil { return nil, queryErr } else if !exists { @@ -661,30 +706,6 @@ func (s *adminServiceImpl) SetAccountTaxonomy(ctx context.Context, accountID int return s.GetAccount(ctx, accountID) } -// A policy may normalize repeated tag IDs, but it cannot change the requested -// folder or tag membership. Validation and normalization remain in the plugin; -// this check protects the immutable mutation targets before opening a transaction. -func validateTaxonomyAssignmentIntent(input AccountTaxonomyAssignment, plan extensionv1.TaxonomyAssignmentPlan) error { - if (input.FolderID == nil) != (plan.FolderID == nil) || - (input.FolderID != nil && *input.FolderID != *plan.FolderID) { - return ErrExtensionOperationUnavailable - } - requested := make(map[int64]struct{}, len(input.TagIDs)) - for _, id := range input.TagIDs { - requested[id] = struct{}{} - } - if len(requested) != len(plan.TagIDs) { - return ErrExtensionOperationUnavailable - } - for _, id := range plan.TagIDs { - if _, ok := requested[id]; !ok || id <= 0 { - return ErrExtensionOperationUnavailable - } - delete(requested, id) - } - return nil -} - func (s *adminServiceImpl) accountConsoleQuery(filters AccountConsoleFilters) *dbent.AccountQuery { query := s.entClient.Account.Query() if values := normalizeStringFilter(filters.Platforms); len(values) > 0 { diff --git a/backend/internal/service/admin_account_taxonomy_test.go b/backend/internal/service/admin_account_taxonomy_test.go index 94c11cb20355..4293e8098ec5 100644 --- a/backend/internal/service/admin_account_taxonomy_test.go +++ b/backend/internal/service/admin_account_taxonomy_test.go @@ -2,87 +2,60 @@ package service import ( "context" - "encoding/json" - "errors" "testing" "time" - "entgo.io/ent/dialect" - entsql "entgo.io/ent/dialect/sql" - "github.com/DATA-DOG/go-sqlmock" - dbent "github.com/Wei-Shaw/sub2api/ent" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) -func replaceAccountTools(t *testing.T, invoke func(context.Context, extensionv1.Invocation) (extensionv1.Result, error)) { - t.Helper() - previous := invokeAccountTools - t.Cleanup(func() { invokeAccountTools = previous }) - invokeAccountTools = invoke -} +func TestAccountTaxonomyRulesRejectAmbiguityAndInvalidAssignments(t *testing.T) { + require.NoError(t, validateTaxonomyOrderIDs([]int64{1, 2}, []int64{2, 1})) + require.Equal(t, "ACCOUNT_TAXONOMY_ORDER_INVALID", infraerrors.Reason(validateTaxonomyOrderIDs([]int64{1, 2}, []int64{1, 1}))) + changed := validateTaxonomyOrderIDs([]int64{1, 2, 3}, []int64{2, 1}) + require.Equal(t, "ACCOUNT_TAXONOMY_ORDER_CHANGED", infraerrors.Reason(changed)) + require.Equal(t, 409, infraerrors.Code(changed)) -func TestSetAccountTaxonomyRejectsChangedAssignmentPlanBeforeTransaction(t *testing.T) { - folder, otherFolder := int64(7), int64(8) for _, tc := range []struct { - name string - plan extensionv1.TaxonomyAssignmentPlan + input BulkAccountTaxonomyInput + reason string }{ - {"changed-folder", extensionv1.TaxonomyAssignmentPlan{FolderID: &otherFolder, TagIDs: []int64{5, 6}}}, - {"cleared-folder", extensionv1.TaxonomyAssignmentPlan{TagIDs: []int64{5, 6}}}, - {"added-tag", extensionv1.TaxonomyAssignmentPlan{FolderID: &folder, TagIDs: []int64{5, 6, 9}}}, - {"omitted-tag", extensionv1.TaxonomyAssignmentPlan{FolderID: &folder, TagIDs: []int64{5}}}, + {BulkAccountTaxonomyInput{FolderAction: "clear"}, "ACCOUNT_TAXONOMY_TARGET_INVALID"}, + {BulkAccountTaxonomyInput{AccountIDs: []int64{1}, TagAddIDs: []int64{2}, TagRemoveIDs: []int64{2}}, "ACCOUNT_TAXONOMY_TAG_OPERATION_CONFLICT"}, + {BulkAccountTaxonomyInput{AccountIDs: []int64{1}, TagAddIDs: []int64{2}, TagRemoveIDs: []int64{3}}, ""}, } { - t.Run(tc.name, func(t *testing.T) { - replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { - raw, err := json.Marshal(tc.plan) - return extensionv1.Result{Payload: raw}, err - }) - db, mock, err := sqlmock.New() + err := ValidateBulkAccountTaxonomy(tc.input) + if tc.reason == "" { require.NoError(t, err) - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - t.Cleanup(func() { _ = client.Close() }) - svc := &adminServiceImpl{entClient: client} - account, err := svc.SetAccountTaxonomy(context.Background(), 42, AccountTaxonomyAssignment{FolderID: &folder, TagIDs: []int64{5, 5, 6}}) - require.ErrorIs(t, err, ErrExtensionOperationUnavailable, "a plan cannot replace or expand the user's assignment") - require.Nil(t, account) - require.NoError(t, mock.ExpectationsWereMet(), "no transaction or query should be started for a changed assignment") - }) + continue + } + require.Equal(t, tc.reason, infraerrors.Reason(err)) + require.Equal(t, 400, infraerrors.Code(err)) } -} -func TestTaxonomyAssignmentPreservesPolicyNormalizationAndFailsClosed(t *testing.T) { - folder := int64(7) - input := AccountTaxonomyAssignment{FolderID: &folder, TagIDs: []int64{6, 5, 6}} - var plan extensionv1.TaxonomyAssignmentPlan - require.NoError(t, accountToolsOperation(context.Background(), "taxonomy.assignment", extensionv1.TaxonomyAssignmentPlan{FolderID: input.FolderID, TagIDs: input.TagIDs}, &plan)) - require.Equal(t, []int64{6, 5}, plan.TagIDs, "the policy owns stable deduplication") - require.NoError(t, validateTaxonomyAssignmentIntent(input, plan)) - require.NoError(t, validateTaxonomyAssignmentIntent(AccountTaxonomyAssignment{}, extensionv1.TaxonomyAssignmentPlan{})) - replaceAccountTools(t, func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, errors.New("policy failed") - }) - svc := &adminServiceImpl{} - account, err := svc.SetAccountTaxonomy(context.Background(), 42, input) - require.Error(t, err) - require.Equal(t, "account tools are unavailable: policy failed", infraerrors.Message(err), "administrators see the underlying failure") - require.Nil(t, account, "a failed policy must stop before using the nil database") - result, err := svc.BulkUpdateAccountTaxonomy(context.Background(), BulkAccountTaxonomyInput{AccountIDs: []int64{42}, FolderAction: "clear"}) - require.Error(t, err) - require.Nil(t, result) + // An invalid assignment or delete target is refused before the database is used. + svc, nonPositive := &adminServiceImpl{}, int64(0) + account, err := svc.SetAccountTaxonomy(context.Background(), 42, AccountTaxonomyAssignment{TagIDs: []int64{-1}}) + require.Equal(t, "ACCOUNT_TAG_ID_INVALID", infraerrors.Reason(err)) + require.Nil(t, account) + account, err = svc.SetAccountTaxonomy(context.Background(), 42, AccountTaxonomyAssignment{FolderID: &nonPositive}) + require.Equal(t, "ACCOUNT_FOLDER_ID_INVALID", infraerrors.Reason(err)) + require.Nil(t, account) + require.Equal(t, "ACCOUNT_TAXONOMY_ID_INVALID", infraerrors.Reason(svc.DeleteAccountFolder(context.Background(), 0, false))) + require.Equal(t, "ACCOUNT_TAXONOMY_ID_INVALID", infraerrors.Reason(svc.DeleteAccountTag(context.Background(), -1))) + require.Equal(t, []int64{6, 5}, uniquePositiveIDs([]int64{6, 5, 6}), "repeated tag IDs keep their first position") } func TestNormalizeAccountTaxonomyNameTrimsAndBuildsCaseInsensitiveKey(t *testing.T) { - display, normalized, err := normalizeAccountTaxonomyNameContext(context.Background(), " Production ") + display, normalized, err := normalizeAccountTaxonomyName(" Production ") require.NoError(t, err) require.Equal(t, "Production", display) require.Equal(t, "production", normalized) - _, _, err = normalizeAccountTaxonomyNameContext(context.Background(), " ") - require.Error(t, err) - _, _, err = normalizeAccountTaxonomyNameContext(context.Background(), string(make([]rune, 101))) - require.Error(t, err) + for _, invalid := range []string{" ", string(make([]rune, 101)), "bad\x00name"} { + _, _, err = normalizeAccountTaxonomyName(invalid) + require.Equal(t, "ACCOUNT_TAXONOMY_NAME_INVALID", infraerrors.Reason(err)) + } } func TestFilterConsoleAccountsCombinesDerivedStatusAndPlanAcrossFullSet(t *testing.T) { diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 197a70c9327d..b00b03d43fbb 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -611,25 +611,6 @@ func (s *APIKeyService) List(ctx context.Context, userID int64, params paginatio return keys, pagination, nil } -// ListAll returns every API key owned by one user after applying the supplied -// filters. It is intended for bounded management-plane aggregations that must -// not truncate at a pagination boundary. -func (s *APIKeyService) ListAll(ctx context.Context, userID int64, filters APIKeyListFilters) ([]APIKey, error) { - if s == nil || s.apiKeyRepo == nil { - return nil, fmt.Errorf("list all api keys: repository is unavailable") - } - repo, ok := s.apiKeyRepo.(apiKeyAllByUserIDLister) - if !ok { - return nil, fmt.Errorf("list all api keys: repository does not support unpaginated API key listing") - } - keys, err := repo.ListAllByUserID(ctx, userID, filters) - if err != nil { - return nil, fmt.Errorf("list all api keys: %w", err) - } - s.fillCurrentConcurrency(ctx, keys) - return keys, nil -} - func (s *APIKeyService) listByCurrentConcurrency(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) { repo, ok := s.apiKeyRepo.(apiKeyAllByUserIDLister) if !ok { diff --git a/backend/internal/service/deepseek_pricing.go b/backend/internal/service/deepseek_pricing.go index c1818d7d4c18..8e59767f60c1 100644 --- a/backend/internal/service/deepseek_pricing.go +++ b/backend/internal/service/deepseek_pricing.go @@ -7,13 +7,12 @@ import ( "time" ) -const deepseekPricingSource = "https://api-docs.deepseek.com/quick_start/pricing/" - var deepseekPeakHours = [][2]int{{9, 12}, {14, 18}} // The registry is the single source of DeepSeek identity and official off-peak -// USD rates. Verified 2026-09-16: Pro remains available at Pro rates; the -// previously announced September 14 migration to Flash was withdrawn. +// USD rates (https://api-docs.deepseek.com/quick_start/pricing/). Verified +// 2026-09-16: Pro remains available at Pro rates; the previously announced +// September 14 migration to Flash was withdrawn. var deepseekPricingRegistry = []struct { canonical string aliases []string @@ -26,7 +25,6 @@ var deepseekPricingRegistry = []struct { type deepseekPricingIdentity struct { CanonicalModel string Match string // exact, alias, or fallback; fallback is never identified pricing - Source string OfficialPeak bool } @@ -42,7 +40,7 @@ func deepseekBillingIdentity(model string, allowFallback bool) (deepseekPricingI if name == card.canonical { match = "exact" } - return deepseekPricingIdentity{card.canonical, match, deepseekPricingSource, true}, true + return deepseekPricingIdentity{card.canonical, match, true}, true } } } @@ -51,7 +49,7 @@ func deepseekBillingIdentity(model string, allowFallback bool) (deepseekPricingI if strings.HasPrefix(bare, "deepseek-v4-pro") { canonical = "deepseek-v4-pro" } - return deepseekPricingIdentity{canonical, "fallback", deepseekPricingSource, true}, true + return deepseekPricingIdentity{canonical, "fallback", true}, true } return deepseekPricingIdentity{}, false } @@ -136,7 +134,6 @@ func (s *PricingService) mergeDeepSeekPricing(data map[string]*LiteLLMModelPrici _ = json.Unmarshal(patch, &fields) for field := range fields { if strings.Contains(field, "_cost_") || strings.HasSuffix(field, "_cost") { - identity.Source = "override" identity.OfficialPeak = false } } diff --git a/backend/internal/service/deepseek_pricing_test.go b/backend/internal/service/deepseek_pricing_test.go index 502d6918c966..0d46f020c176 100644 --- a/backend/internal/service/deepseek_pricing_test.go +++ b/backend/internal/service/deepseek_pricing_test.go @@ -249,7 +249,7 @@ func TestDeepseekEffectiveCatalogAndIdentity(t *testing.T) { require.NoError(t, err) require.Equal(t, card.canonical, price.deepseekIdentity.CanonicalModel) require.NotEqual(t, "fallback", price.deepseekIdentity.Match) - require.Equal(t, deepseekPricingSource, price.deepseekIdentity.Source) + require.True(t, price.deepseekIdentity.OfficialPeak) require.InDelta(t, card.input, price.InputPricePerToken, 1e-15) require.InDelta(t, card.output, price.OutputPricePerToken, 1e-15) require.InDelta(t, card.cacheRead, price.CacheReadPricePerToken, 1e-15) @@ -281,7 +281,6 @@ func TestDeepseekPriceLoadAndHotReload(t *testing.T) { price := svc.GetModelPricing(alias) require.InDelta(t, 4e-6, price.InputCostPerToken, 1e-15) require.InDelta(t, 6e-7, price.OutputCostPerToken, 1e-15) - require.Equal(t, "override", price.deepseekIdentity.Source) require.False(t, price.deepseekIdentity.OfficialPeak) anchor := svc.localHash require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{"deepseek-flash":{"input_cost_per_token":0,"output_cost_per_token":0,"cache_read_input_token_cost":0}}`), 0644)) @@ -422,5 +421,5 @@ func TestDeepseekCacheOnlyAdminOverride(t *testing.T) { cost, err := bs.CalculateCostUnified(CostInput{Model: "deepseek/deepseek-v4.1-flash", Tokens: UsageTokens{CacheReadTokens: 1000}, RateMultiplier: 1, PricingAt: time.Date(2026, 9, 16, 2, 0, 0, 0, time.UTC)}) require.NoError(t, err) require.InDelta(t, .004, cost.TotalCost, 1e-12) - require.Equal(t, "override", svc.GetModelPricing("deepseek-flash").deepseekIdentity.Source) + require.False(t, svc.GetModelPricing("deepseek-flash").deepseekIdentity.OfficialPeak) } diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 51147fdab02f..e4945431baba 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -577,14 +577,14 @@ func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Cont if !stream { return ctx, func() {} } - return detachPolicyContext(ctx) + return context.WithoutCancel(ctx), func() {} } func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { return context.Background(), func() {} } - return detachPolicyContext(ctx) + return context.WithoutCancel(ctx), func() {} } // billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供) diff --git a/backend/internal/service/image_studio_artifact.go b/backend/internal/service/image_studio_artifact.go deleted file mode 100644 index 5805ac274bf4..000000000000 --- a/backend/internal/service/image_studio_artifact.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strings" - "sync" - "time" -) - -var imageStudioStorageKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.(png|jpg|webp)$`) - -type ImageStudioArtifactStore struct { - root string - repo ImageStudioRepository - mu sync.Mutex -} - -func NewImageStudioArtifactStore(root string, repo ImageStudioRepository) *ImageStudioArtifactStore { - root = strings.TrimSpace(root) - if root == "" { - root = filepath.Join("data", "image-studio") - } - return &ImageStudioArtifactStore{root: filepath.Clean(root), repo: repo} -} - -func (s *ImageStudioArtifactStore) Save( - ctx context.Context, - userID int64, - kind ImageStudioArtifactKind, - data []byte, - contentType string, -) (ImageStudioInputArtifact, error) { - contentType, extension, err := validateImageStudioImage(data, contentType) - if err != nil { - return ImageStudioInputArtifact{}, err - } - if kind != ImageStudioArtifactReference && kind != ImageStudioArtifactMask && kind != ImageStudioArtifactOutput { - return ImageStudioInputArtifact{}, newImageStudioError(400, "invalid_artifact", "Image artifact type is not supported") - } - if userID <= 0 || s == nil || s.repo == nil { - return ImageStudioInputArtifact{}, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - - s.mu.Lock() - defer s.mu.Unlock() - usage, err := s.repo.StorageUsage(ctx, userID, time.Now()) - if err != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("load image studio storage usage: %w", err) - } - byteSize := int64(len(data)) - if byteSize > ImageStudioGlobalBytes-usage.Global || byteSize > ImageStudioUserBytes-usage.User { - return ImageStudioInputArtifact{}, newImageStudioError(409, "storage_quota_exceeded", "Image Studio storage quota is full") - } - if err = os.MkdirAll(s.root, 0o700); err != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("create image studio storage: %w", err) - } - - storageKey, err := newImageStudioStorageKey(extension) - if err != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("create image studio storage key: %w", err) - } - path, err := s.path(storageKey) - if err != nil { - return ImageStudioInputArtifact{}, err - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("create image studio artifact: %w", err) - } - written, writeErr := file.Write(data) - closeErr := file.Close() - if writeErr != nil || written != len(data) || closeErr != nil { - _ = os.Remove(path) - if writeErr != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("write image studio artifact: %w", writeErr) - } - if closeErr != nil { - return ImageStudioInputArtifact{}, fmt.Errorf("close image studio artifact: %w", closeErr) - } - return ImageStudioInputArtifact{}, io.ErrShortWrite - } - return ImageStudioInputArtifact{ - Kind: kind, StorageKey: storageKey, ContentType: contentType, ByteSize: byteSize, - }, nil -} - -func (s *ImageStudioArtifactStore) Open(storageKey string) (io.ReadCloser, error) { - path, err := s.path(storageKey) - if err != nil { - return nil, err - } - file, err := os.Open(path) - if errors.Is(err, os.ErrNotExist) { - return nil, ErrImageStudioNotFound - } - if err != nil { - return nil, fmt.Errorf("open image studio artifact: %w", err) - } - return file, nil -} - -func (s *ImageStudioArtifactStore) Read(storageKey string) ([]byte, error) { - reader, err := s.Open(storageKey) - if err != nil { - return nil, err - } - defer func() { _ = reader.Close() }() - data, err := io.ReadAll(io.LimitReader(reader, ImageStudioMaxImageBytes+1)) - if err != nil { - return nil, fmt.Errorf("read image studio artifact: %w", err) - } - if len(data) > ImageStudioMaxImageBytes { - return nil, newImageStudioError(400, "invalid_image", "Image exceeds the 20 MB limit") - } - return data, nil -} - -func (s *ImageStudioArtifactStore) Remove(storageKey string) error { - path, err := s.path(storageKey) - if err != nil { - return err - } - err = os.Remove(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("remove image studio artifact: %w", err) - } - return nil -} - -func (s *ImageStudioArtifactStore) path(storageKey string) (string, error) { - if s == nil || !imageStudioStorageKeyPattern.MatchString(storageKey) { - return "", newImageStudioError(400, "invalid_artifact", "Image artifact is invalid") - } - return filepath.Join(s.root, storageKey), nil -} - -func newImageStudioStorageKey(extension string) (string, error) { - random := make([]byte, 16) - if _, err := rand.Read(random); err != nil { - return "", err - } - return hex.EncodeToString(random) + "." + extension, nil -} - -func validateImageStudioImage(data []byte, declared string) (contentType, extension string, err error) { - if len(data) == 0 || len(data) > ImageStudioMaxImageBytes { - return "", "", newImageStudioError(400, "invalid_image", "Image is empty or exceeds the 20 MB limit") - } - detected := "" - switch { - case len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n": - detected = "image/png" - extension = "png" - case len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff: - detected = "image/jpeg" - extension = "jpg" - case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP": - detected = "image/webp" - extension = "webp" - default: - return "", "", newImageStudioError(400, "invalid_image", "Image format is not supported") - } - declared = strings.ToLower(strings.TrimSpace(strings.SplitN(declared, ";", 2)[0])) - if declared != "" && declared != detected { - return "", "", newImageStudioError(400, "invalid_image", "Image content type does not match its contents") - } - return detected, extension, nil -} diff --git a/backend/internal/service/image_studio_artifact_test.go b/backend/internal/service/image_studio_artifact_test.go deleted file mode 100644 index 4e6a8c4d8f2c..000000000000 --- a/backend/internal/service/image_studio_artifact_test.go +++ /dev/null @@ -1,62 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "io" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -type imageStudioUsageRepoStub struct { - ImageStudioRepository - usage ImageStudioStorageUsage -} - -func (s *imageStudioUsageRepoStub) StorageUsage(context.Context, int64, time.Time) (ImageStudioStorageUsage, error) { - return s.usage, nil -} - -func TestImageStudioArtifactStoreWritesOpaqueValidatedFile(t *testing.T) { - store := NewImageStudioArtifactStore(t.TempDir(), &imageStudioUsageRepoStub{}) - png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3} - - artifact, err := store.Save(context.Background(), 7, ImageStudioArtifactReference, png, "image/png") - require.NoError(t, err) - require.NotContains(t, artifact.StorageKey, "/") - require.NotContains(t, artifact.StorageKey, `\`) - require.Equal(t, int64(len(png)), artifact.ByteSize) - - reader, err := store.Open(artifact.StorageKey) - require.NoError(t, err) - t.Cleanup(func() { _ = reader.Close() }) - got, err := io.ReadAll(reader) - require.NoError(t, err) - require.Equal(t, png, got) -} - -func TestImageStudioArtifactStoreRejectsQuotaBeforeWriting(t *testing.T) { - store := NewImageStudioArtifactStore(t.TempDir(), &imageStudioUsageRepoStub{usage: ImageStudioStorageUsage{ - Global: ImageStudioGlobalBytes - 1, - User: ImageStudioUserBytes - 1, - }}) - png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3} - - _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, png, "image/png") - var studioErr *ImageStudioError - require.ErrorAs(t, err, &studioErr) - require.Equal(t, "storage_quota_exceeded", studioErr.Code) -} - -func TestImageStudioArtifactStoreRejectsMismatchedContentType(t *testing.T) { - store := NewImageStudioArtifactStore(t.TempDir(), &imageStudioUsageRepoStub{}) - jpeg := []byte{0xff, 0xd8, 0xff, 0xdb, 1, 2, 3} - - _, err := store.Save(context.Background(), 7, ImageStudioArtifactOutput, jpeg, "image/png") - var studioErr *ImageStudioError - require.ErrorAs(t, err, &studioErr) - require.Equal(t, "invalid_image", studioErr.Code) -} diff --git a/backend/internal/service/image_studio_job.go b/backend/internal/service/image_studio_job.go deleted file mode 100644 index efdcc07ea0df..000000000000 --- a/backend/internal/service/image_studio_job.go +++ /dev/null @@ -1,255 +0,0 @@ -package service - -import ( - "context" - "errors" - "io" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -const ( - ImageStudioModelGPTImage2 = extensionv1.ImageStudioModelGPTImage2 - ImageStudioModelGeminiProImage = extensionv1.ImageStudioModelGeminiProImage - - ImageStudioMaxOutputCount = extensionv1.ImageStudioMaxOutputCount - ImageStudioMaxImageBytes = 20 << 20 - ImageStudioGlobalBytes = int64(2 << 30) - ImageStudioUserBytes = int64(250 << 20) - - ImageStudioFileRetention = 24 * time.Hour - ImageStudioMetadataRetention = 30 * 24 * time.Hour -) - -type ImageStudioMode string - -const ( - ImageStudioModeGenerate ImageStudioMode = "generate" - ImageStudioModeEdit ImageStudioMode = "edit" -) - -type ImageStudioJobStatus string - -const ( - ImageStudioJobPending ImageStudioJobStatus = "pending" - ImageStudioJobPreparing ImageStudioJobStatus = "preparing" - ImageStudioJobRunning ImageStudioJobStatus = "running" - ImageStudioJobSucceeded ImageStudioJobStatus = "succeeded" - ImageStudioJobPartiallySucceeded ImageStudioJobStatus = "partially_succeeded" - ImageStudioJobFailed ImageStudioJobStatus = "failed" - ImageStudioJobCanceled ImageStudioJobStatus = "canceled" - ImageStudioJobCanceledWithResults ImageStudioJobStatus = "canceled_with_results" -) - -type ImageStudioItemStatus string - -const ( - ImageStudioItemPending ImageStudioItemStatus = "pending" - ImageStudioItemRunning ImageStudioItemStatus = "running" - ImageStudioItemSucceeded ImageStudioItemStatus = "succeeded" - ImageStudioItemFailed ImageStudioItemStatus = "failed" - ImageStudioItemCanceled ImageStudioItemStatus = "canceled" -) - -type ImageStudioArtifactKind string - -const ( - ImageStudioArtifactReference ImageStudioArtifactKind = "reference" - ImageStudioArtifactMask ImageStudioArtifactKind = "mask" - ImageStudioArtifactOutput ImageStudioArtifactKind = "output" -) - -type ImageStudioError struct { - Status int - Code string - Message string -} - -func (e *ImageStudioError) Error() string { - if e == nil { - return "" - } - return e.Message -} - -func newImageStudioError(status int, code, message string) error { - return &ImageStudioError{Status: status, Code: code, Message: message} -} - -var ( - ErrImageStudioNotFound = errors.New("image studio job not found") - ErrImageStudioNoWork = errors.New("no image studio work available") - ErrImageStudioActiveJob = errors.New("an image studio job is already active") - ErrImageStudioNotRetryable = errors.New("image studio job is not retryable") - ErrImageStudioRequestExpired = errors.New("image studio job input has expired") -) - -type ImageStudioCreateInput struct { - APIKeyID int64 - Mode ImageStudioMode - Model string - Prompt string - Count int - Size string - Quality string -} - -type ImageStudioCounts struct { - Processed int `json:"processed"` - Succeeded int `json:"succeeded"` - Failed int `json:"failed"` - Canceled int `json:"canceled"` -} - -type ImageStudioJob struct { - ID int64 `json:"id"` - UserID int64 `json:"-"` - APIKeyID int64 `json:"api_key_id"` - Mode ImageStudioMode `json:"mode"` - Model string `json:"model"` - Prompt string `json:"-"` - Size string `json:"size,omitempty"` - Quality string `json:"quality,omitempty"` - Count int `json:"count"` - Status ImageStudioJobStatus `json:"status"` - Counts ImageStudioCounts `json:"counts"` - CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - RequestExpiresAt time.Time `json:"request_expires_at"` - RetainUntil time.Time `json:"retain_until"` - StartedAt *time.Time `json:"started_at,omitempty"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (j ImageStudioJob) Terminal() bool { - switch j.Status { - case ImageStudioJobSucceeded, ImageStudioJobPartiallySucceeded, ImageStudioJobFailed, - ImageStudioJobCanceled, ImageStudioJobCanceledWithResults: - return true - default: - return false - } -} - -type ImageStudioItem struct { - ID int64 `json:"id"` - JobID int64 `json:"job_id"` - Ordinal int `json:"ordinal"` - Status ImageStudioItemStatus `json:"status"` - ErrorCode string `json:"error_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - StartedAt *time.Time `json:"started_at,omitempty"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ImageStudioArtifact struct { - ID int64 `json:"id"` - JobID int64 `json:"job_id"` - ItemID *int64 `json:"item_id,omitempty"` - Kind ImageStudioArtifactKind `json:"kind"` - StorageKey string `json:"-"` - ContentType string `json:"content_type"` - ByteSize int64 `json:"byte_size"` - RevisedPrompt string `json:"revised_prompt,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` -} - -type ImageStudioInputArtifact struct { - Kind ImageStudioArtifactKind - StorageKey string - ContentType string - ByteSize int64 -} - -type ImageStudioCreateParams struct { - UserID int64 - Input ImageStudioCreateInput - RequestExpiresAt time.Time - RetainUntil time.Time - InputArtifacts []ImageStudioInputArtifact -} - -type ImageStudioClaim struct { - Job ImageStudioJob - Item ImageStudioItem - Inputs []ImageStudioArtifact -} - -type ImageStudioExecutionRequest struct { - Job ImageStudioJob - Item ImageStudioItem - APIKey *APIKey - Reference []byte - ReferenceContentType string - Mask []byte - MaskContentType string - // Origin is the client that submitted or retried the job, when this - // process still knows it. - Origin ImageStudioRequestOrigin -} - -type ImageStudioExecutionResult struct { - Data []byte - ContentType string - RevisedPrompt string -} - -type ImageStudioExecutor interface { - Execute(ctx context.Context, request ImageStudioExecutionRequest) (*ImageStudioExecutionResult, error) -} - -type ImageStudioFileStorage interface { - Save(ctx context.Context, userID int64, kind ImageStudioArtifactKind, data []byte, contentType string) (ImageStudioInputArtifact, error) - Open(storageKey string) (io.ReadCloser, error) - Read(storageKey string) ([]byte, error) - Remove(storageKey string) error -} - -type ImageStudioStorageUsage struct { - Global int64 - User int64 -} - -type ImageStudioRepository interface { - Create(ctx context.Context, params ImageStudioCreateParams) (*ImageStudioJob, error) - Get(ctx context.Context, userID, jobID int64) (*ImageStudioJob, error) - List(ctx context.Context, userID int64, limit, offset int) ([]ImageStudioJob, error) - ListItems(ctx context.Context, userID, jobID int64) ([]ImageStudioItem, error) - ListOutputArtifacts(ctx context.Context, userID, jobID int64) ([]ImageStudioArtifact, error) - GetArtifact(ctx context.Context, userID, jobID, artifactID int64) (*ImageStudioArtifact, error) - RequestCancel(ctx context.Context, userID, jobID int64) (*ImageStudioJob, error) - Retry(ctx context.Context, userID, jobID int64, now time.Time) (*ImageStudioJob, error) - RecoverInterrupted(ctx context.Context) error - ClaimNext(ctx context.Context) (*ImageStudioClaim, error) - CompleteSuccess(ctx context.Context, itemID int64, artifact ImageStudioInputArtifact, revisedPrompt string, expiresAt time.Time) error - CompleteFailure(ctx context.Context, itemID int64, code, message string) error - Finalize(ctx context.Context, jobID int64) (*ImageStudioJob, error) - StorageUsage(ctx context.Context, userID int64, now time.Time) (ImageStudioStorageUsage, error) - ListExpiredArtifacts(ctx context.Context, now time.Time, limit int) ([]ImageStudioArtifact, error) - DeleteArtifact(ctx context.Context, artifactID int64) error - ExpireRequests(ctx context.Context, now time.Time) error - DeleteExpiredJobs(ctx context.Context, now time.Time) error -} - -func ResolveImageStudioTerminalStatus(cancelRequested bool, counts ImageStudioCounts) ImageStudioJobStatus { - if cancelRequested { - if counts.Succeeded > 0 { - return ImageStudioJobCanceledWithResults - } - return ImageStudioJobCanceled - } - if counts.Succeeded == counts.Processed && counts.Processed > 0 { - return ImageStudioJobSucceeded - } - if counts.Succeeded > 0 { - return ImageStudioJobPartiallySucceeded - } - return ImageStudioJobFailed -} diff --git a/backend/internal/service/image_studio_job_service.go b/backend/internal/service/image_studio_job_service.go deleted file mode 100644 index 4f31ba4172dd..000000000000 --- a/backend/internal/service/image_studio_job_service.go +++ /dev/null @@ -1,221 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "strings" - "time" -) - -type ImageStudioUpload struct { - Data []byte - ContentType string -} - -type ImageStudioEligibleKeyGroup struct { - ID int64 `json:"id"` - Name string `json:"name"` -} - -type ImageStudioEligibleAPIKey struct { - ID int64 `json:"id"` - Name string `json:"name"` - GroupID int64 `json:"group_id"` - Group ImageStudioEligibleKeyGroup `json:"group"` -} - -type ImageStudioEligibleKey struct { - APIKey ImageStudioEligibleAPIKey `json:"api_key"` -} - -type ImageStudioArtifactDownload struct { - Artifact *ImageStudioArtifact - Reader io.ReadCloser -} - -type ImageStudioService struct { - runtime *ImageStudioRuntime - repo ImageStudioRepository - apiKeys APIKeyRepository - store ImageStudioFileStorage - now func() time.Time - origins imageStudioOrigins -} - -func NewImageStudioService( - repo ImageStudioRepository, - apiKeys APIKeyRepository, - store ImageStudioFileStorage, -) *ImageStudioService { - return &ImageStudioService{ - repo: repo, apiKeys: apiKeys, store: store, now: time.Now, - } -} - -// imageStudioModelSource lists the image models Image Studio may offer. The -// retired provider catalog was its only source, so it stays empty until a -// generic image model source is added; without models no API key is eligible. -var imageStudioModelSource []string - -func imageStudioGroupEligible(group *Group) bool { - return group != nil && group.ID > 0 && group.IsActive() && group.AllowImageGeneration && - len(imageStudioModelSource) > 0 -} - -// EligibleKeys lists the caller's active API keys whose group allows image -// generation while Image Studio has a model source. -func (s *ImageStudioService) EligibleKeys(ctx context.Context, userID int64) ([]ImageStudioEligibleKey, error) { - if s == nil || s.apiKeys == nil || userID <= 0 { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - lister, ok := s.apiKeys.(apiKeyAllByUserIDLister) - if !ok { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - keys, err := lister.ListAllByUserID(ctx, userID, APIKeyListFilters{Status: StatusActive}) - if err != nil { - return nil, fmt.Errorf("list image studio keys: %w", err) - } - items := make([]ImageStudioEligibleKey, 0, len(keys)) - for i := range keys { - key := &keys[i] - if key.UserID != userID || !key.IsActive() || key.IsExpired() || key.IsQuotaExhausted() || !imageStudioGroupEligible(key.Group) { - continue - } - items = append(items, ImageStudioEligibleKey{APIKey: ImageStudioEligibleAPIKey{ - ID: key.ID, Name: key.Name, GroupID: key.Group.ID, - Group: ImageStudioEligibleKeyGroup{ID: key.Group.ID, Name: key.Group.Name}, - }}) - } - return items, nil -} - -func (s *ImageStudioService) eligibleAPIKey(ctx context.Context, userID, apiKeyID int64) (*APIKey, error) { - if s == nil || s.apiKeys == nil { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - key, err := s.apiKeys.GetByID(ctx, apiKeyID) - if err != nil || key == nil || key.UserID != userID || !key.IsActive() || key.IsExpired() || key.IsQuotaExhausted() || !imageStudioGroupEligible(key.Group) { - return nil, newImageStudioError(404, "api_key_unavailable", "Image Studio API key is unavailable") - } - return key, nil -} - -func (s *ImageStudioService) Create( - ctx context.Context, - userID int64, - input ImageStudioCreateInput, - reference, mask *ImageStudioUpload, -) (*ImageStudioJob, error) { - input.Prompt = strings.TrimSpace(input.Prompt) - plan, err := planImageStudio(ctx, input, reference != nil, mask != nil) - if err != nil { - return nil, err - } - input.Model, input.Mode, input.Size, input.Quality = plan.Model, ImageStudioMode(plan.Mode), plan.Size, plan.Quality - if _, err := s.eligibleAPIKey(ctx, userID, input.APIKeyID); err != nil { - return nil, err - } - if s.repo == nil || s.store == nil { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - if s.runtime != nil { - if err := s.runtime.Start(context.Background()); err != nil { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - } - now := s.now() - expiresAt := now.Add(ImageStudioFileRetention) - saved := make([]ImageStudioInputArtifact, 0, 2) - removeSaved := func() { - for _, artifact := range saved { - _ = s.store.Remove(artifact.StorageKey) - } - } - if reference != nil { - artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactReference, reference.Data, reference.ContentType) - if err != nil { - return nil, err - } - saved = append(saved, artifact) - } - if mask != nil { - artifact, err := s.store.Save(ctx, userID, ImageStudioArtifactMask, mask.Data, mask.ContentType) - if err != nil { - removeSaved() - return nil, err - } - saved = append(saved, artifact) - } - job, err := s.repo.Create(ctx, ImageStudioCreateParams{ - UserID: userID, Input: input, RequestExpiresAt: expiresAt, - RetainUntil: now.Add(ImageStudioMetadataRetention), InputArtifacts: saved, - }) - if err != nil { - removeSaved() - return nil, err - } - s.origins.remember(job.ID, imageStudioRequestOriginFromContext(ctx), now) - return job, nil -} - -func (s *ImageStudioService) Get(ctx context.Context, userID, jobID int64) (*ImageStudioJob, error) { - return s.repo.Get(ctx, userID, jobID) -} - -func (s *ImageStudioService) List(ctx context.Context, userID int64, limit, offset int) ([]ImageStudioJob, error) { - return s.repo.List(ctx, userID, limit, offset) -} - -func (s *ImageStudioService) ListItems(ctx context.Context, userID, jobID int64) ([]ImageStudioItem, error) { - return s.repo.ListItems(ctx, userID, jobID) -} - -func (s *ImageStudioService) ListArtifacts(ctx context.Context, userID, jobID int64) ([]ImageStudioArtifact, error) { - return s.repo.ListOutputArtifacts(ctx, userID, jobID) -} - -func (s *ImageStudioService) Cancel(ctx context.Context, userID, jobID int64) (*ImageStudioJob, error) { - return s.repo.RequestCancel(ctx, userID, jobID) -} - -func (s *ImageStudioService) Retry(ctx context.Context, userID, jobID int64) (*ImageStudioJob, error) { - if err := EnsureImageStudioAvailable(ctx); err != nil { - return nil, err - } - job, err := s.repo.Get(ctx, userID, jobID) - if err != nil { - return nil, err - } - if _, err = s.eligibleAPIKey(ctx, userID, job.APIKeyID); err != nil { - return nil, err - } - if s.runtime != nil { - if err := s.runtime.Start(context.Background()); err != nil { - return nil, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - } - now := s.now() - s.origins.remember(jobID, imageStudioRequestOriginFromContext(ctx), now) - return s.repo.Retry(ctx, userID, jobID, now) -} - -func (s *ImageStudioService) OpenArtifact(ctx context.Context, userID, jobID, artifactID int64) (*ImageStudioArtifactDownload, error) { - artifact, err := s.repo.GetArtifact(ctx, userID, jobID, artifactID) - if err != nil { - return nil, err - } - if !artifact.ExpiresAt.After(s.now()) { - return nil, ErrImageStudioNotFound - } - reader, err := s.store.Open(artifact.StorageKey) - if err != nil { - if errors.Is(err, ErrImageStudioNotFound) { - return nil, ErrImageStudioNotFound - } - return nil, err - } - return &ImageStudioArtifactDownload{Artifact: artifact, Reader: reader}, nil -} diff --git a/backend/internal/service/image_studio_job_service_test.go b/backend/internal/service/image_studio_job_service_test.go deleted file mode 100644 index 98f23e1f8656..000000000000 --- a/backend/internal/service/image_studio_job_service_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "io" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -type imageStudioAPIKeyRepoFake struct { - APIKeyRepository - keys []APIKey -} - -func (f *imageStudioAPIKeyRepoFake) ListAllByUserID(context.Context, int64, APIKeyListFilters) ([]APIKey, error) { - return append([]APIKey(nil), f.keys...), nil -} - -func (f *imageStudioAPIKeyRepoFake) GetByID(_ context.Context, id int64) (*APIKey, error) { - for i := range f.keys { - if f.keys[i].ID == id { - copy := f.keys[i] - return ©, nil - } - } - return nil, ErrAPIKeyNotFound -} - -type imageStudioRepoFake struct { - ImageStudioRepository - created ImageStudioCreateParams - job *ImageStudioJob -} - -func (f *imageStudioRepoFake) Create(_ context.Context, params ImageStudioCreateParams) (*ImageStudioJob, error) { - f.created = params - return f.job, nil -} - -type imageStudioStoreFake struct { - saved []ImageStudioInputArtifact - removed []string -} - -func (f *imageStudioStoreFake) Save(_ context.Context, _ int64, kind ImageStudioArtifactKind, data []byte, contentType string) (ImageStudioInputArtifact, error) { - artifact := ImageStudioInputArtifact{Kind: kind, StorageKey: string(kind) + ".png", ContentType: contentType, ByteSize: int64(len(data))} - f.saved = append(f.saved, artifact) - return artifact, nil -} - -func (f *imageStudioStoreFake) Open(string) (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader("image")), nil -} - -func (f *imageStudioStoreFake) Read(string) ([]byte, error) { return []byte("image"), nil } -func (f *imageStudioStoreFake) Remove(key string) error { - f.removed = append(f.removed, key) - return nil -} - -func canonicalImageStudioFixture() APIKey { - group := &Group{ID: 31, Name: "Images", Platform: PlatformOpenAI, Status: StatusActive, AllowImageGeneration: true} - return APIKey{ID: 41, UserID: 7, Key: "must-never-leave-server", Name: "Studio", Status: StatusActive, GroupID: &group.ID, Group: group} -} - -func TestImageStudioEligibleKeysHidesUnavailableImageCandidatesAndCredentials(t *testing.T) { - key := canonicalImageStudioFixture() - legacyGroup := &Group{ID: 32, Name: "legacy", Platform: PlatformOpenAI, Status: StatusActive, AllowImageGeneration: true} - legacyKey := APIKey{ID: 42, UserID: 7, Key: "legacy-secret", Name: "legacy", Status: StatusActive, GroupID: &legacyGroup.ID, Group: legacyGroup} - studio := NewImageStudioService( - &imageStudioRepoFake{}, - &imageStudioAPIKeyRepoFake{keys: []APIKey{key, legacyKey}}, - &imageStudioStoreFake{}, - ) - - items, err := studio.EligibleKeys(context.Background(), 7) - require.NoError(t, err) - require.Empty(t, items, "no Image Studio model source exists after the provider catalog removal") - - raw, err := json.Marshal(items) - require.NoError(t, err) - require.NotContains(t, string(raw), "must-never-leave-server") - require.NotContains(t, string(raw), `"key"`) -} diff --git a/backend/internal/service/image_studio_job_test.go b/backend/internal/service/image_studio_job_test.go deleted file mode 100644 index 2aa08c8f4847..000000000000 --- a/backend/internal/service/image_studio_job_test.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "fmt" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -func TestPlanImageStudioEnforcesFixedModelsAndNativeOneFanout(t *testing.T) { - previous := imageToolsConfigOverride.Load() - t.Cleanup(func() { imageToolsConfigOverride.Store(previous) }) - ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) - tests := []struct { - name string - input ImageStudioCreateInput - hasReference bool - hasMask bool - wantCode string - }{ - { - name: "gpt generation fans out four one-image items", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeGenerate, Model: "gpt-image-2", - Prompt: "draw a launch vehicle", Count: 4, Size: "1024x1024", Quality: "low", - }, - }, - { - name: "gemini edit accepts reference and mask", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeEdit, Model: "gemini-3-pro-image", - Prompt: "replace the sky", Count: 2, Size: "1024x1024", Quality: "low", - }, - hasReference: true, - hasMask: true, - }, - { - name: "gpt edit is not inferred", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeEdit, Model: "gpt-image-2", - Prompt: "edit", Count: 1, - }, - hasReference: true, - wantCode: "unsupported_mode", - }, - { - name: "gemini edit needs reference", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeEdit, Model: "gemini-3-pro-image", - Prompt: "edit", Count: 1, - }, - wantCode: "reference_required", - }, - { - name: "mask is edit only", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeGenerate, Model: "gemini-3-pro-image", - Prompt: "draw", Count: 1, - }, - hasMask: true, - wantCode: "mask_not_allowed", - }, - { - name: "count above four is rejected", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeGenerate, Model: "gpt-image-2", - Prompt: "draw", Count: 5, - }, - wantCode: "invalid_count", - }, - { - name: "unknown model is rejected", - input: ImageStudioCreateInput{ - APIKeyID: 4, Mode: ImageStudioModeGenerate, Model: "gpt-image-3", - Prompt: "draw", Count: 1, - }, - wantCode: "unsupported_model", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := planImageStudio(context.Background(), tt.input, tt.hasReference, tt.hasMask) - if tt.wantCode == "" { - require.NoError(t, err) - return - } - var studioErr *ImageStudioError - require.ErrorAs(t, err, &studioErr) - require.Equal(t, tt.wantCode, studioErr.Code) - }) - } -} - -func TestResolveImageStudioTerminalStatusPreservesPartialAndCanceledResults(t *testing.T) { - tests := []struct { - name string - cancelRequested bool - counts ImageStudioCounts - want ImageStudioJobStatus - }{ - {"all succeeded", false, ImageStudioCounts{Processed: 4, Succeeded: 4}, ImageStudioJobSucceeded}, - {"partial", false, ImageStudioCounts{Processed: 4, Succeeded: 2, Failed: 2}, ImageStudioJobPartiallySucceeded}, - {"all failed", false, ImageStudioCounts{Processed: 4, Failed: 4}, ImageStudioJobFailed}, - {"canceled no result", true, ImageStudioCounts{Processed: 4, Canceled: 4}, ImageStudioJobCanceled}, - {"canceled with result", true, ImageStudioCounts{Processed: 4, Succeeded: 1, Canceled: 3}, ImageStudioJobCanceledWithResults}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, ResolveImageStudioTerminalStatus(tt.cancelRequested, tt.counts)) - }) - } -} - -// A gateway failure keeps its status and body for administrators (server log -// and Ops); the job item shows the generic user-facing message. -func TestImageStudioGatewayErrorShowsGenericUserMessage(t *testing.T) { - err := fmt.Errorf("generate: %w", &ImageStudioGatewayError{StatusCode: 502, Body: `{"error":{"message":"upstream account 7 was banned"}}`}) - code, message := imageStudioSafeExecutionError(context.Background(), err) - require.Equal(t, "generation_failed", code) - require.Equal(t, "Image generation failed", message) -} diff --git a/backend/internal/service/image_studio_runtime.go b/backend/internal/service/image_studio_runtime.go deleted file mode 100644 index ee6fd9a47c28..000000000000 --- a/backend/internal/service/image_studio_runtime.go +++ /dev/null @@ -1,384 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "strings" - "sync" - "time" -) - -// ImageStudioRequestOrigin is the client address and User-Agent of the request -// that submitted or retried an Image Studio job. -type ImageStudioRequestOrigin struct { - ClientIP string - UserAgent string -} - -type imageStudioRequestOriginKey struct{} - -// WithImageStudioRequestOrigin records the submitting client for Create/Retry. -func WithImageStudioRequestOrigin(ctx context.Context, origin ImageStudioRequestOrigin) context.Context { - return context.WithValue(ctx, imageStudioRequestOriginKey{}, origin) -} - -func imageStudioRequestOriginFromContext(ctx context.Context) ImageStudioRequestOrigin { - if ctx == nil { - return ImageStudioRequestOrigin{} - } - origin, _ := ctx.Value(imageStudioRequestOriginKey{}).(ImageStudioRequestOrigin) - return origin -} - -// ImageStudioRequestOriginFromContext returns the Image Studio submitter that -// an in-process gateway request carries for its usage and Ops records. The -// gateway request itself carries no client headers, because the images -// handler forwards request headers to API-key upstreams. -func ImageStudioRequestOriginFromContext(ctx context.Context) (ImageStudioRequestOrigin, bool) { - origin := imageStudioRequestOriginFromContext(ctx) - return origin, strings.TrimSpace(origin.ClientIP) != "" || strings.TrimSpace(origin.UserAgent) != "" -} - -// ImageStudioClientRequestIDPrefix starts the client request ID of every Image -// Studio gateway request. It marks the Ops rows that stay admin-only. -const ImageStudioClientRequestIDPrefix = "image-studio-" - -// imageStudioClientRequestIDMaxLen keeps "client:"+ID, the usage billing -// dedupe key, within the 64-character request ID columns. -const imageStudioClientRequestIDMaxLen = 57 - -// NewImageStudioClientRequestID returns the client request ID of one gateway -// attempt of a job item. It ties Ops and usage rows to the item and is the -// usage billing dedupe key, so every attempt gets its own random suffix: a -// retried or re-run item is billed again instead of being deduplicated. -func NewImageStudioClientRequestID(jobID, itemID int64) string { - var nonce [6]byte - _, _ = rand.Read(nonce[:]) - id := fmt.Sprintf("%sjob-%d-item-%d-%s", ImageStudioClientRequestIDPrefix, jobID, itemID, hex.EncodeToString(nonce[:])) - if len(id) > imageStudioClientRequestIDMaxLen { - var wide [16]byte - _, _ = rand.Read(wide[:]) - id = ImageStudioClientRequestIDPrefix + hex.EncodeToString(wide[:]) - } - return id -} - -// imageStudioLogLimit bounds the error text an Image Studio failure writes to -// the server log; a response body can be an entire image. -const imageStudioLogLimit = 4 << 10 - -// ImageStudioLogText returns at most the first 4 KiB of a text for the server -// log, noting how long the full text was. -func ImageStudioLogText(text string) string { - if len(text) <= imageStudioLogLimit { - return strings.ToValidUTF8(text, "\uFFFD") - } - return strings.ToValidUTF8(text[:imageStudioLogLimit], "\uFFFD") + fmt.Sprintf("...<%d bytes in total>", len(text)) -} - -// imageStudioOrigins keeps the submitting client of recent jobs in memory, so -// the gateway request of a job item carries the real client IP and User-Agent -// into usage and Ops records. Without a database column it does not survive a -// restart, and a job processed by another instance falls back to no origin. -type imageStudioOrigins struct { - mu sync.Mutex - entries map[int64]imageStudioOriginEntry -} - -type imageStudioOriginEntry struct { - origin ImageStudioRequestOrigin - at time.Time -} - -func (o *imageStudioOrigins) remember(jobID int64, origin ImageStudioRequestOrigin, now time.Time) { - if o == nil || jobID <= 0 || (origin.ClientIP == "" && origin.UserAgent == "") { - return - } - o.mu.Lock() - defer o.mu.Unlock() - if o.entries == nil { - o.entries = make(map[int64]imageStudioOriginEntry) - } - for id, entry := range o.entries { - if now.Sub(entry.at) > ImageStudioFileRetention { - delete(o.entries, id) - } - } - o.entries[jobID] = imageStudioOriginEntry{origin: origin, at: now} -} - -func (o *imageStudioOrigins) lookup(jobID int64) ImageStudioRequestOrigin { - if o == nil { - return ImageStudioRequestOrigin{} - } - o.mu.Lock() - defer o.mu.Unlock() - return o.entries[jobID].origin -} - -// ImageStudioGatewayError keeps the HTTP status and body the image gateway -// returned for a failed Image Studio request. The server log and the Ops error -// record keep them; the job item shows its user-facing message. -type ImageStudioGatewayError struct { - StatusCode int - Body string -} - -func (e *ImageStudioGatewayError) Error() string { - return fmt.Sprintf("image gateway returned %d: %s", e.StatusCode, e.Body) -} - -type ImageStudioRuntimeOptions struct { - Workers int - PollInterval time.Duration - CleanupInterval time.Duration -} - -func (o ImageStudioRuntimeOptions) normalized() ImageStudioRuntimeOptions { - if o.Workers <= 0 || o.Workers > 4 { - o.Workers = 4 - } - if o.PollInterval <= 0 { - o.PollInterval = 250 * time.Millisecond - } - if o.CleanupInterval <= 0 { - o.CleanupInterval = 10 * time.Minute - } - return o -} - -type ImageStudioRuntime struct { - repo ImageStudioRepository - studio *ImageStudioService - store ImageStudioFileStorage - executor ImageStudioExecutor - options ImageStudioRuntimeOptions - - mu sync.Mutex - cancel context.CancelFunc - done chan struct{} - started bool -} - -func NewImageStudioRuntime( - repo ImageStudioRepository, - studio *ImageStudioService, - store ImageStudioFileStorage, - executor ImageStudioExecutor, - options ImageStudioRuntimeOptions, -) *ImageStudioRuntime { - runtime := &ImageStudioRuntime{ - repo: repo, studio: studio, store: store, executor: executor, options: options.normalized(), - } - if studio != nil { - studio.runtime = runtime - } - return runtime -} - -func (r *ImageStudioRuntime) Start(parent context.Context) error { - if r == nil || r.repo == nil || r.studio == nil || r.store == nil || r.executor == nil { - return errors.New("image studio runtime dependencies are unavailable") - } - r.mu.Lock() - defer r.mu.Unlock() - if r.started { - return nil - } - if err := r.repo.RecoverInterrupted(parent); err != nil { - return fmt.Errorf("recover interrupted image studio jobs: %w", err) - } - ctx, cancel := context.WithCancel(parent) - done := make(chan struct{}) - r.cancel = cancel - r.done = done - r.started = true - - var workers sync.WaitGroup - workers.Add(r.options.Workers + 1) - for workerID := 0; workerID < r.options.Workers; workerID++ { - go func() { - defer workers.Done() - r.worker(ctx) - }() - } - go func() { - defer workers.Done() - r.cleanupLoop(ctx) - }() - go func() { - workers.Wait() - close(done) - }() - return nil -} - -func (r *ImageStudioRuntime) Stop(ctx context.Context) error { - if r == nil { - return nil - } - r.mu.Lock() - if !r.started { - r.mu.Unlock() - return nil - } - cancel, done := r.cancel, r.done - r.mu.Unlock() - cancel() - select { - case <-done: - r.mu.Lock() - r.started = false - r.cancel = nil - r.done = nil - r.mu.Unlock() - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (r *ImageStudioRuntime) worker(ctx context.Context) { - for { - if ctx.Err() != nil { - return - } - claim, err := r.repo.ClaimNext(ctx) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return - } - if !waitForImageStudioPoll(ctx, r.options.PollInterval) { - return - } - continue - } - r.processClaim(ctx, claim) - } -} - -func waitForImageStudioPoll(ctx context.Context, duration time.Duration) bool { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return false - case <-timer.C: - return true - } -} - -func (r *ImageStudioRuntime) processClaim(ctx context.Context, claim *ImageStudioClaim) { - if claim == nil { - return - } - job := claim.Job - ctx, release := bindImageStudioEnabled(ctx) - defer release() - if err := EnsureImageStudioAvailable(ctx); err != nil { - r.failClaim(ctx, job.ID, claim.Item.ID, err) - return - } - key, err := r.studio.eligibleAPIKey(ctx, job.UserID, job.APIKeyID) - if err != nil { - r.failClaim(ctx, job.ID, claim.Item.ID, err) - return - } - request := ImageStudioExecutionRequest{Job: job, Item: claim.Item, APIKey: key, Origin: r.studio.origins.lookup(job.ID)} - for _, artifact := range claim.Inputs { - data, readErr := r.store.Read(artifact.StorageKey) - if readErr != nil { - r.failClaim(ctx, job.ID, claim.Item.ID, newImageStudioError(409, "input_expired", "Image Studio input is unavailable")) - return - } - switch artifact.Kind { - case ImageStudioArtifactReference: - request.Reference = data - request.ReferenceContentType = artifact.ContentType - case ImageStudioArtifactMask: - request.Mask = data - request.MaskContentType = artifact.ContentType - } - } - result, err := r.executor.Execute(ctx, request) - if err != nil || result == nil { - if err == nil { - err = errors.New("empty image studio result") - } - r.failClaim(ctx, job.ID, claim.Item.ID, err) - return - } - expiresAt := time.Now().Add(ImageStudioFileRetention) - artifact, err := r.store.Save(ctx, job.UserID, ImageStudioArtifactOutput, result.Data, result.ContentType) - if err != nil { - r.failClaim(ctx, job.ID, claim.Item.ID, err) - return - } - if err = r.repo.CompleteSuccess(ctx, claim.Item.ID, artifact, result.RevisedPrompt, expiresAt); err != nil { - _ = r.store.Remove(artifact.StorageKey) - r.failClaim(ctx, job.ID, claim.Item.ID, errors.New("persist image result")) - return - } - _, _ = r.repo.Finalize(ctx, job.ID) -} - -func (r *ImageStudioRuntime) failClaim(ctx context.Context, jobID, itemID int64, err error) { - code, message := imageStudioSafeExecutionError(ctx, err) - if err != nil { - // The job item keeps its user-facing message; administrators get the - // original error (for a gateway failure its status and body) here. - slog.Warn("image_studio.item_failed", "job_id", jobID, "item_id", itemID, "code", code, "error", ImageStudioLogText(err.Error())) - } - finishCtx := context.WithoutCancel(ctx) - if completeErr := r.repo.CompleteFailure(finishCtx, itemID, code, message); completeErr != nil { - return - } - _, _ = r.repo.Finalize(finishCtx, jobID) -} - -func imageStudioSafeExecutionError(ctx context.Context, err error) (string, string) { - if ctx != nil && ctx.Err() != nil { - return "interrupted", "Image generation was interrupted" - } - var studioErr *ImageStudioError - if errors.As(err, &studioErr) && studioErr != nil && studioErr.Code != "" { - return studioErr.Code, studioErr.Message - } - return "generation_failed", "Image generation failed" -} - -func (r *ImageStudioRuntime) cleanupLoop(ctx context.Context) { - r.cleanup(ctx) - ticker := time.NewTicker(r.options.CleanupInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - r.cleanup(ctx) - } - } -} - -func (r *ImageStudioRuntime) cleanup(ctx context.Context) { - if !ImageStudioFeatureEnabled() { - return - } - now := time.Now() - _ = r.repo.ExpireRequests(ctx, now) - artifacts, err := r.repo.ListExpiredArtifacts(ctx, now, 100) - if err == nil { - for _, artifact := range artifacts { - if removeErr := r.store.Remove(artifact.StorageKey); removeErr != nil { - continue - } - _ = r.repo.DeleteArtifact(ctx, artifact.ID) - } - } - _ = r.repo.DeleteExpiredJobs(ctx, now) -} diff --git a/backend/internal/service/image_tools.go b/backend/internal/service/image_tools.go deleted file mode 100644 index bfcb3bb2a63d..000000000000 --- a/backend/internal/service/image_tools.go +++ /dev/null @@ -1,134 +0,0 @@ -package service - -import ( - "context" - "strings" - "sync" - "sync/atomic" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -var imageToolsConfigOverride atomic.Pointer[extensionv1.ImageToolsConfig] - -// EffectiveImageToolsConfig returns the Image Studio switch this process -// applies: the stored administrator setting once loaded, otherwise the -// deploy-time flag (the value the former image-tools plugin was seeded with). -func EffectiveImageToolsConfig() extensionv1.ImageToolsConfig { - if config := imageToolsConfigOverride.Load(); config != nil { - return *config - } - return LegacyImageToolsConfig() -} - -// imageStudioEnabledFromEnvironment is the deploy-time -// GATEWAY_IMAGE_STUDIO_ENABLED value, read once at startup. -var imageStudioEnabledFromEnvironment = func() bool { - enabled, _ := config.ResolveImageStudioEnabledFromEnvironment() - return enabled -}() - -// LegacyImageToolsConfig returns the deploy-time switch used until an -// administrator stores image_tools_config. -func LegacyImageToolsConfig() extensionv1.ImageToolsConfig { - return extensionv1.ImageToolsConfig{StudioEnabled: imageStudioEnabledFromEnvironment} -} - -// ImageStudioFeatureEnabled reports whether Image Studio is switched on. -func ImageStudioFeatureEnabled() bool { - return EffectiveImageToolsConfig().StudioEnabled -} - -// imageStudioStop is closed whenever Image Studio is switched off so running -// executions stop, as the former plugin's policy lease did. -var imageStudioStop = struct { - mu sync.Mutex - ch chan struct{} -}{ch: make(chan struct{})} - -// imageStudioStarter starts the Image Studio background runtime. The server -// registers it so that switching Image Studio on after boot needs no restart. -var imageStudioStarter atomic.Pointer[func()] - -// SetImageStudioStarter registers the runtime start used when Image Studio is -// switched on after boot. Starting an already running runtime is a no-op. -func SetImageStudioStarter(start func()) { - imageStudioStarter.Store(&start) -} - -// ConfigureImageTools installs the effective switches (startup load, admin -// update, tests). A nil value falls back to the deploy-time rollout flags. -func ConfigureImageTools(config *extensionv1.ImageToolsConfig) { - imageToolsConfigOverride.Store(config) - if ImageStudioFeatureEnabled() { - if start := imageStudioStarter.Load(); start != nil { - (*start)() - } - return - } - imageStudioStop.mu.Lock() - close(imageStudioStop.ch) - imageStudioStop.ch = make(chan struct{}) - imageStudioStop.mu.Unlock() -} - -// bindImageStudioEnabled returns a context that is canceled when Image Studio -// is switched off. The switch is also registered as a policy signal: upstream -// image IO detaches from request cancellation (detachUpstreamContext) but still -// honours policy signals, so switching off stops an in-flight generation -// instead of billing a result that can no longer be saved. -func bindImageStudioEnabled(ctx context.Context) (context.Context, context.CancelFunc) { - imageStudioStop.mu.Lock() - stop := imageStudioStop.ch - imageStudioStop.mu.Unlock() - signal, stopSignal := context.WithCancel(context.Background()) - signals, _ := ctx.Value(policyCancellationSignalsKey{}).([]context.Context) - ctx = context.WithValue(ctx, policyCancellationSignalsKey{}, append(append([]context.Context{}, signals...), signal)) - ctx, cancel := context.WithCancel(ctx) - go func() { - select { - case <-stop: - stopSignal() - cancel() - case <-ctx.Done(): - } - }() - return ctx, func() { - stopSignal() - cancel() - } -} - -func EnsureImageStudioAvailable(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - if !ImageStudioFeatureEnabled() { - return newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") - } - return nil -} - -func planImageStudio(ctx context.Context, input ImageStudioCreateInput, hasReference, hasMask bool) (extensionv1.ImageStudioPlan, error) { - request := extensionv1.ImageStudioPlanRequest{APIKeySelected: input.APIKeyID > 0, Mode: string(input.Mode), Model: input.Model, PromptBytes: len(strings.TrimSpace(input.Prompt)), Count: input.Count, Size: input.Size, Quality: input.Quality, HasReference: hasReference, HasMask: hasMask} - if err := ctx.Err(); err != nil { - return extensionv1.ImageStudioPlan{}, newImageStudioError(503, "studio_unavailable", "Image Studio is unavailable") - } - plan, code, message := planImageStudioRequest(request) - if code != "" { - return plan, newImageStudioError(400, code, message) - } - if !ImageStudioFeatureEnabled() { - return plan, newImageStudioError(404, "studio_disabled", "Image Studio is not enabled") - } - return plan, nil -} - -func PlanImageStudioExecution(ctx context.Context, request ImageStudioExecutionRequest) (extensionv1.ImageStudioPlan, error) { - keyID := request.Job.APIKeyID - if request.APIKey != nil { - keyID = request.APIKey.ID - } - return planImageStudio(ctx, ImageStudioCreateInput{APIKeyID: keyID, Mode: request.Job.Mode, Model: request.Job.Model, Prompt: request.Job.Prompt, Count: 1, Size: request.Job.Size, Quality: request.Job.Quality}, len(request.Reference) > 0, len(request.Mask) > 0) -} diff --git a/backend/internal/service/image_tools_policy.go b/backend/internal/service/image_tools_policy.go deleted file mode 100644 index 149220c13d31..000000000000 --- a/backend/internal/service/image_tools_policy.go +++ /dev/null @@ -1,57 +0,0 @@ -package service - -import ( - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// Image Studio request policy formerly served by the image-tools plugin -// process: a pure check over host facts. - -func planImageStudioRequest(in extensionv1.ImageStudioPlanRequest) (extensionv1.ImageStudioPlan, string, string) { - plan := extensionv1.ImageStudioPlan{Model: strings.TrimSpace(in.Model), Mode: in.Mode, Size: strings.TrimSpace(in.Size), Quality: strings.TrimSpace(in.Quality), OutputPerRequest: 1, ResponseFormat: "b64_json", Endpoint: "/v1/images/generations"} - if !in.APIKeySelected { - return plan, "invalid_api_key", "Select an Image Studio API key" - } - if in.PromptBytes <= 0 || in.PromptBytes > 32000 { - return plan, "invalid_prompt", "Prompt must contain between 1 and 32000 bytes" - } - if in.Count < 1 || in.Count > extensionv1.ImageStudioMaxOutputCount { - return plan, "invalid_count", "Image count must be between 1 and 4" - } - if plan.Model != extensionv1.ImageStudioModelGPTImage2 && plan.Model != extensionv1.ImageStudioModelGeminiProImage { - return plan, "unsupported_model", "Image Studio model is not supported" - } - if in.Mode != "generate" && in.Mode != "edit" { - return plan, "unsupported_mode", "Image Studio mode is not supported" - } - if in.Mode == "edit" && plan.Model != extensionv1.ImageStudioModelGeminiProImage { - return plan, "unsupported_mode", "This model does not support image editing" - } - if in.Mode == "edit" && !in.HasReference { - return plan, "reference_required", "A reference image is required for editing" - } - if in.Mode == "generate" && in.HasReference { - return plan, "reference_not_allowed", "Reference images are only supported for editing" - } - if in.HasMask && (in.Mode != "edit" || plan.Model != extensionv1.ImageStudioModelGeminiProImage) { - return plan, "mask_not_allowed", "Masks are not supported for this request" - } - if plan.Size != "" && plan.Size != "1024x1024" { - return plan, "unsupported_size", "Image size is not supported" - } - if plan.Quality != "" && plan.Quality != "low" { - return plan, "unsupported_quality", "Image quality is not supported" - } - if plan.Size == "" { - plan.Size = "1024x1024" - } - if plan.Quality == "" { - plan.Quality = "low" - } - if in.Mode == "edit" { - plan.Endpoint = "/v1/images/edits" - } - return plan, "", "" -} diff --git a/backend/internal/service/image_tools_policy_test.go b/backend/internal/service/image_tools_policy_test.go deleted file mode 100644 index 23eb442d0a4f..000000000000 --- a/backend/internal/service/image_tools_policy_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestImageToolsStudioPlanRequiresExplicitEnablementAndKeepsNativeOne(t *testing.T) { - t.Cleanup(func() { ConfigureImageTools(nil) }) - input := ImageStudioCreateInput{APIKeyID: 1, Mode: "generate", Model: "gpt-image-2", Prompt: "fixture text", Count: 4} - ConfigureImageTools(&extensionv1.ImageToolsConfig{}) - if _, err := planImageStudio(context.Background(), input, false, false); err == nil { - t.Fatal("default must remain disabled") - } - ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) - plan, err := planImageStudio(context.Background(), input, false, false) - if err != nil || plan.OutputPerRequest != 1 || plan.Size != "1024x1024" || plan.Endpoint != "/v1/images/generations" { - t.Fatalf("unexpected plan: %+v %v", plan, err) - } - input.Mode = "edit" - if _, err := planImageStudio(context.Background(), input, true, false); err == nil { - t.Fatal("unsupported editing must be rejected") - } - input.Model = "gemini-3-pro-image" - plan, err = planImageStudio(context.Background(), input, true, false) - if err != nil || plan.Endpoint != "/v1/images/edits" { - t.Fatalf("supported editing plan unavailable: %+v %v", plan, err) - } -} - -func TestSwitchingImageStudioOnStartsTheRuntime(t *testing.T) { - previousConfig, previousStarter := imageToolsConfigOverride.Load(), imageStudioStarter.Load() - t.Cleanup(func() { - imageToolsConfigOverride.Store(previousConfig) - imageStudioStarter.Store(previousStarter) - }) - starts := 0 - SetImageStudioStarter(func() { starts++ }) - ConfigureImageTools(&extensionv1.ImageToolsConfig{}) - if starts != 0 { - t.Fatalf("switching Image Studio off must not start the runtime, starts=%d", starts) - } - ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) - if starts != 1 { - t.Fatalf("switching Image Studio on must start the runtime once, starts=%d", starts) - } -} - -func TestSwitchingImageStudioOffStopsDetachedUpstreamIO(t *testing.T) { - previous := imageToolsConfigOverride.Load() - t.Cleanup(func() { imageToolsConfigOverride.Store(previous) }) - ConfigureImageTools(&extensionv1.ImageToolsConfig{StudioEnabled: true}) - bound, release := bindImageStudioEnabled(context.Background()) - defer release() - upstream, releaseUpstream := detachUpstreamContext(bound) - defer releaseUpstream() - ConfigureImageTools(&extensionv1.ImageToolsConfig{}) - select { - case <-upstream.Done(): - case <-time.After(5 * time.Second): - t.Fatal("switching Image Studio off must cancel detached upstream IO") - } -} diff --git a/backend/internal/service/native_feature_bootstrap.go b/backend/internal/service/native_feature_bootstrap.go index f1bf3a43e463..0af7e6a97fe7 100644 --- a/backend/internal/service/native_feature_bootstrap.go +++ b/backend/internal/service/native_feature_bootstrap.go @@ -191,22 +191,6 @@ func nativeFeatureSettings(plugin NativeRetirementPlugin, encryptor SecretEncryp return err } switch plugin.Key { - case "codexrip.image-tools": - if err := validateNativeUnmappedBindings(plugin, "extensions.request.v1"); err != nil { - return nil, err - } - var value extensionv1.ImageToolsConfig - // responses_image_enabled (the removed Responses image bridge) is - // accepted in a saved plugin configuration and dropped. - if err := DecodeSwitchSettings(raw, &value, "studio_enabled", "responses_image_enabled"); err != nil { - return nil, fmt.Errorf("invalid saved image tool configuration: %w", err) - } - enabled, err := nativeRetirementBindingEnabled(plugin, "extensions.request.v1", "*", "*") - if err != nil { - return nil, err - } - value.StudioEnabled = value.StudioEnabled && enabled - return settings, put(SettingKeyImageToolsConfig, value) case "codexrip.admin-observability": if err := validateNativeUnmappedBindings(plugin, "extensions.observability.v1", "extensions.ui.v1"); err != nil { return nil, err @@ -290,8 +274,6 @@ func nativeCapabilityScopeKnown(key, capability, platform, accountType string) b return global && (capability == "extensions.admin.v1" || capability == "extensions.ui.v1" || capability == "extensions.observability.v1") case "codexrip.model-policy": return global && (capability == "extensions.admin.v1" || capability == "extensions.catalog.v1") - case "codexrip.image-tools": - return global && (capability == "extensions.admin.v1" || capability == "extensions.request.v1") case "codexrip.prompt-skills": return (global && capability == "extensions.admin.v1") || (capability == "extensions.request.v1" && platform == PlatformOpenAI && accountType == "*") } @@ -320,8 +302,6 @@ func nativeRetirementBindingEnabled(plugin NativeRetirementPlugin, capability, p // database read failure with defaults. func ValidateNativeFeatureSetting(key string, raw []byte) error { switch key { - case SettingKeyImageToolsConfig: - return DecodeSwitchSettings(raw, &extensionv1.ImageToolsConfig{}, "studio_enabled") case SettingKeyAdminObservabilityConfig: return DecodeSwitchSettings(raw, &extensionv1.AdminObservabilityConfig{}, "telemetry_enabled", "theme_enabled") default: diff --git a/backend/internal/service/native_feature_bootstrap_test.go b/backend/internal/service/native_feature_bootstrap_test.go index 566a17c8f4e5..c74fa718d421 100644 --- a/backend/internal/service/native_feature_bootstrap_test.go +++ b/backend/internal/service/native_feature_bootstrap_test.go @@ -28,9 +28,9 @@ func TestNativeFeatureBootstrapPreservesEffectiveConfiguration(t *testing.T) { wantErr bool }{ { - name: "disabled image installation stays off", - plugin: NativeRetirementPlugin{Key: "codexrip.image-tools", State: "disabled", ConfigEncrypted: `{"studio_enabled":true,"responses_image_enabled":true}`}, - key: SettingKeyImageToolsConfig, expected: `{"studio_enabled":false}`, + name: "disabled observability installation stays off", + plugin: NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "disabled", ConfigEncrypted: `{"telemetry_enabled":true,"theme_enabled":true}`}, + key: SettingKeyAdminObservabilityConfig, expected: `{"telemetry_enabled":false,"theme_enabled":false}`, }, { name: "separate theme and telemetry bindings", @@ -93,11 +93,11 @@ func TestNativeFeatureBootstrapPreservesEffectiveConfiguration(t *testing.T) { } }) } - _, err := nativeFeatureSettings(NativeRetirementPlugin{Key: "codexrip.image-tools", State: "disabled", ConfigEncrypted: "private"}, retirementTestEncryptor{fail: true}) + _, err := nativeFeatureSettings(NativeRetirementPlugin{Key: "codexrip.admin-observability", State: "disabled", ConfigEncrypted: "private"}, retirementTestEncryptor{fail: true}) require.Error(t, err) require.NotContains(t, err.Error(), "sensitive ciphertext") - require.Error(t, ValidateNativeFeatureSetting(SettingKeyImageToolsConfig, []byte(`{"studio_enabled":"false"}`))) - require.Error(t, ValidateNativeFeatureSetting(SettingKeyImageToolsConfig, nil)) + require.Error(t, ValidateNativeFeatureSetting(SettingKeyAdminObservabilityConfig, []byte(`{"telemetry_enabled":"false"}`))) + require.Error(t, ValidateNativeFeatureSetting(SettingKeyAdminObservabilityConfig, nil)) } type retiredPluginSourceStub []*PluginInstallation @@ -108,17 +108,17 @@ func (s retiredPluginSourceStub) RetiredPluginInstallations(context.Context) ([] func TestNativeFeatureBootstrapListsRetiredPluginsWithDecryptedConfig(t *testing.T) { snapshot := &NativeRetirementSnapshot{Version: 1, Completed: true, Plugins: map[string]NativeRetirementPlugin{ - "codexrip.image-tools": {ID: 3, Key: "codexrip.image-tools", State: "enabled"}, + "codexrip.admin-observability": {ID: 3, Key: "codexrip.admin-observability", State: "enabled"}, }} bootstrap := &NativeFeatureBootstrap{Snapshot: snapshot, encryptor: retirementTestEncryptor{}} bootstrap.SetRetiredPluginSource(retiredPluginSourceStub{{ - ID: 3, PluginKey: "codexrip.image-tools", State: "disabled", LastError: "stopped by retirement", - ConfigEncrypted: `{"studio_enabled":true,"api_key":"saved-secret"}`, + ID: 3, PluginKey: "codexrip.admin-observability", State: "disabled", LastError: "stopped by retirement", + ConfigEncrypted: `{"telemetry_enabled":true,"api_key":"saved-secret"}`, }}) view, err := bootstrap.RetiredPlugins(context.Background()) require.NoError(t, err) require.Same(t, snapshot, view.Receipt) require.Len(t, view.Installations, 1) require.Equal(t, "stopped by retirement", view.Installations[0].LastError) - require.JSONEq(t, `{"studio_enabled":true,"api_key":"saved-secret"}`, string(view.Installations[0].Config)) + require.JSONEq(t, `{"telemetry_enabled":true,"api_key":"saved-secret"}`, string(view.Installations[0].Config)) } diff --git a/backend/internal/service/native_policy_errors.go b/backend/internal/service/native_policy_errors.go deleted file mode 100644 index a97036dcdd81..000000000000 --- a/backend/internal/service/native_policy_errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package service - -import "errors" - -// Retain existing error identities for the built-in policy callers and API contracts. -var ErrExtensionOperationDisabled = errors.New("extension operation is not enabled") -var ErrExtensionOperationUnavailable = errors.New("enabled extension operation is unavailable") diff --git a/backend/internal/service/native_policy_fixtures.go b/backend/internal/service/native_policy_fixtures.go deleted file mode 100644 index 02c63439ed2f..000000000000 --- a/backend/internal/service/native_policy_fixtures.go +++ /dev/null @@ -1,18 +0,0 @@ -package service - -import ( - "context" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// ConfigureNativePolicyOperations is the in-process composition seam used by -// cross-package contract fixtures. -// It has no plugin registry, process transport, capabilities or lifecycle side effects. -func ConfigureNativePolicyOperations(invoker extensionv1.OperationInvoker) { - invokeAccountTools = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if invoker == nil { - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - return invoker.InvokeOperation(ctx, in) - } -} diff --git a/backend/internal/service/native_setting_load_test.go b/backend/internal/service/native_setting_load_test.go index 6bee8235efc6..c7fda2ce243f 100644 --- a/backend/internal/service/native_setting_load_test.go +++ b/backend/internal/service/native_setting_load_test.go @@ -22,15 +22,12 @@ func (r *nativeSwitchReadRepository) GetValue(context.Context, string) (string, } func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { - oldImage := EffectiveImageToolsConfig() oldObservability := EffectiveAdminObservabilityConfig() oldCodexRuntime := EffectiveCodexRuntimeConfig() t.Cleanup(func() { - ConfigureImageTools(&oldImage) ConfigureAdminObservability(&oldObservability) SetCodexRequestZstdEnabled(oldCodexRuntime.RequestZstd) }) - ConfigureImageTools(&extensionv1.ImageToolsConfig{}) ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) SetCodexRequestZstdEnabled(false) for _, test := range []struct { @@ -40,22 +37,21 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { }{ {name: "database error", err: errors.New("fixture database unavailable")}, {name: "invalid JSON", value: `{"broken"`}, - {name: "invalid value type", value: `{"studio_enabled":"false"}`}, + {name: "invalid value type", value: `{"telemetry_enabled":"false"}`}, {name: "empty existing key", value: ""}, } { t.Run(test.name, func(t *testing.T) { svc := NewSettingService(&nativeSwitchReadRepository{value: test.value, err: test.err}, nil) - for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { + for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { require.Error(t, load(context.Background())) } - require.Equal(t, extensionv1.ImageToolsConfig{}, EffectiveImageToolsConfig()) require.Equal(t, extensionv1.AdminObservabilityConfig{}, EffectiveAdminObservabilityConfig()) require.Equal(t, extensionv1.CodexRuntimeConfig{}, EffectiveCodexRuntimeConfig()) }) } svc := NewSettingService(&nativeSwitchReadRepository{err: ErrSettingNotFound}, nil) var value map[string]json.RawMessage - found, err := svc.readNativeSwitchSetting(context.Background(), SettingKeyImageToolsConfig, &value, "studio_enabled") + found, err := svc.readNativeSwitchSetting(context.Background(), SettingKeyAdminObservabilityConfig, &value, "telemetry_enabled", "theme_enabled") require.NoError(t, err) require.False(t, found) } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index b0ff0d5d2f90..ebbe8af6976f 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -914,7 +914,7 @@ func TestOpenAIRuntimeBreaker_LateSuccessDoesNotClearActiveCooldown(t *testing.T account := &Account{ID: 4706, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} svc.BlockAccountScheduling(account, time.Now().Add(time.Minute), "concurrent_failure") - svc.ReportOpenAIAccountScheduleResult(account.ID, "gpt-5.4", true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, account.ID, "gpt-5.4", true, nil) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) cache.mu.Lock() @@ -932,7 +932,7 @@ func TestOpenAIRuntimeBreaker_LateSuccessDoesNotClearActiveModelCooldown(t *test StatusCode: http.StatusServiceUnavailable, }) svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute)) - svc.ReportOpenAIAccountScheduleResult(account.ID, "gpt-5.4", true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, account.ID, "gpt-5.4", true, nil) require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "cleared DB cooldown must release only the stale account scope") diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index c181cd040a84..8f6a12c86f4e 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -85,7 +85,7 @@ func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T) } require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) - svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, account.ID, canonicalModel, true, nil) require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"), "a late success must not clear an active cooldown") key, ok := openAIAccountModelTransientKey(account.ID, canonicalModel) @@ -96,7 +96,7 @@ func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T) svc.openaiModelTransient.entries[key] = entry svc.openaiModelTransient.mu.Unlock() - svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, account.ID, canonicalModel, true, nil) require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) } diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 0f6337babc58..2254f819a5bc 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -2715,10 +2715,6 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport } -func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) { - s.reportOpenAIAccountScheduleResult("", accountID, model, success, firstTokenMs) -} - func (s *OpenAIGatewayService) ReleaseOpenAIRuntimeBreakerProbeForSelection(selection *AccountSelectionResult) { if selection == nil { return diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index a9af7bca3ce6..58b5907719f2 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -1683,7 +1683,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, 10, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -2933,7 +2933,7 @@ func TestReportOpenAIAccountScheduleResult_SuccessClearsOnlyExpiredModelTransien svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond)) require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) - svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, 21636, "gpt-5.5", true, nil) require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)), "a late success must not clear an active cooldown") key, ok := openAIAccountModelTransientKey(21636, "gpt-5.5") @@ -2944,7 +2944,7 @@ func TestReportOpenAIAccountScheduleResult_SuccessClearsOnlyExpiredModelTransien svc.openaiModelTransient.entries[key] = entry svc.openaiModelTransient.mu.Unlock() - svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, 21636, "gpt-5.5", true, nil) require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) } @@ -3403,7 +3403,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) { selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120)) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, account.ID, "", true, intPtrForTest(120)) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -3740,7 +3740,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) { svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) + svc.ReportOpenAIAccountScheduleResultForSelection(nil, 10, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 9fdbd4d15943..fd4c16b9a172 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -823,7 +823,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco wsRetryLoop: for attempt := 1; attempt <= maxAttempts; attempt++ { wsAttempts = attempt - wsResult, wsErr = s.forwardOpenAIWSV2WithScope( + wsResult, wsErr = s.forwardOpenAIWSV2( ctx, c, account, diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 3440f65a9111..2f2b00f606d0 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -185,9 +185,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = nextBody } - normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBodyForModel( - body, isOpenAIResponsesCompactPath(c), gjson.GetBytes(body, "model").String(), - ) + normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c)) if err != nil { return nil, err } @@ -231,9 +229,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } if account != nil && account.IsOpenAI() { responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) - normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyForModel( - body, account, responsesLite, gjson.GetBytes(body, "model").String(), - ) + normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite) if normalizeErr != nil { return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 647e20bb302e..600f4e323c6b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1649,10 +1649,6 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBodyForModel(body []byte, acc // 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 // 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) { - return normalizeOpenAIPassthroughOAuthBodyForModel(body, compact, gjson.GetBytes(body, "model").String()) -} - -func normalizeOpenAIPassthroughOAuthBodyForModel(body []byte, compact bool, resolvedModel string) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } @@ -1661,7 +1657,7 @@ func normalizeOpenAIPassthroughOAuthBodyForModel(body []byte, compact bool, reso if err != nil { return body, false, err } - if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, resolvedModel); reasoningErr != nil { + if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, ""); reasoningErr != nil { return body, false, reasoningErr } else if reasoningChanged { normalized = reasoningBody diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index fe0055c36288..88ba9c742104 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -192,13 +192,6 @@ func TestNormalizeOpenAIResponsesReasoningModeForModel_UsesResolvedTarget(t *tes func TestNormalizeOpenAICompatibilityBodiesForModel_PreserveMappedAstraMode(t *testing.T) { body := []byte(`{"model":"assistant","input":[],"reasoning":{"mode":"pro","context":"all_turns"}}`) - for _, compact := range []bool{false, true} { - normalized, _, err := normalizeOpenAIPassthroughOAuthBodyForModel(body, compact, "gpt-6-astra") - require.NoError(t, err) - require.Equal(t, "assistant", gjson.GetBytes(normalized, "model").String()) - require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String()) - require.False(t, gjson.GetBytes(normalized, "reasoning.effort").Exists()) - } for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} { for _, target := range []string{"gpt-6-astra", "gpt-5.4"} { normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBodyForModel(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false, target) diff --git a/backend/internal/service/openai_refusal_recovery.go b/backend/internal/service/openai_refusal_recovery.go index 4878590eafb7..754e0d060fe1 100644 --- a/backend/internal/service/openai_refusal_recovery.go +++ b/backend/internal/service/openai_refusal_recovery.go @@ -130,18 +130,11 @@ func ValidateOpenAIRefusalRecoverySettings(settings *SystemSettings) error { return nil } -func (m *OpenAIRefusalMatcher) MatchFirstParagraph(text string) (bool, string) { - if m == nil { - return false, "" - } - return m.matchText(firstOpenAIRefusalParagraph(text)) -} - func (m *OpenAIRefusalMatcher) MatchLeadingParagraphs(text string) (bool, string) { if m == nil { return false, "" } - return m.matchText(leadingOpenAIRefusalParagraphs(text, maxOpenAIRefusalScanParagraphs)) + return m.matchText(leadingOpenAIRefusalParagraphs(text)) } func (m *OpenAIRefusalMatcher) matchText(text string) (bool, string) { @@ -176,14 +169,10 @@ func normalizeOpenAIRefusalText(value string) string { return strings.Join(strings.Fields(value), " ") } -func firstOpenAIRefusalParagraph(value string) string { - return leadingOpenAIRefusalParagraphs(value, 1) -} - -func leadingOpenAIRefusalParagraphs(value string, limit int) string { - if limit <= 0 { - return "" - } +// leadingOpenAIRefusalParagraphs returns the window the matcher inspects: the +// first maxOpenAIRefusalScanParagraphs paragraphs, capped at +// maxOpenAIRefusalParagraphRunes. +func leadingOpenAIRefusalParagraphs(value string) string { value = strings.ReplaceAll(value, "\r\n", "\n") value = strings.ReplaceAll(value, "\r", "\n") lines := strings.Split(value, "\n") @@ -196,7 +185,7 @@ func leadingOpenAIRefusalParagraphs(value string, limit int) string { continue } paragraphsComplete++ - if paragraphsComplete >= limit { + if paragraphsComplete >= maxOpenAIRefusalScanParagraphs { break } selected = append(selected, "") @@ -222,7 +211,7 @@ type openAIRefusalEvidence struct { } func newOpenAIRefusalEvidence(keyword, visibleText string) openAIRefusalEvidence { - return openAIRefusalEvidence{Keyword: keyword, Text: leadingOpenAIRefusalParagraphs(visibleText, maxOpenAIRefusalScanParagraphs)} + return openAIRefusalEvidence{Keyword: keyword, Text: leadingOpenAIRefusalParagraphs(visibleText)} } const ( diff --git a/backend/internal/service/openai_refusal_recovery_json.go b/backend/internal/service/openai_refusal_recovery_json.go index e38646fccc31..74b117af8bdf 100644 --- a/backend/internal/service/openai_refusal_recovery_json.go +++ b/backend/internal/service/openai_refusal_recovery_json.go @@ -24,13 +24,9 @@ type openAIResponsesRewriteContentItem struct { Refusal string `json:"refusal,omitempty"` } -func RewriteOpenAIResponsesJSON(body []byte, matcher *OpenAIRefusalMatcher) ([]byte, bool, string, error) { - rewritten, matched, evidence, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) - return rewritten, matched, evidence.Keyword, err -} - -// rewriteOpenAIResponsesJSONWithEvidence also returns the leading original text -// the matcher inspected, so the rewrite can be audited by administrators. +// rewriteOpenAIResponsesJSONWithEvidence replaces a matched refusal and returns +// the keyword with the leading original text the matcher inspected, so the +// rewrite can be audited by administrators. func rewriteOpenAIResponsesJSONWithEvidence(body []byte, matcher *OpenAIRefusalMatcher) ([]byte, bool, openAIRefusalEvidence, error) { var none openAIRefusalEvidence var envelope openAIResponsesRewriteEnvelope diff --git a/backend/internal/service/openai_refusal_recovery_stream.go b/backend/internal/service/openai_refusal_recovery_stream.go index 77cc49a1d45d..b3dcdae2358c 100644 --- a/backend/internal/service/openai_refusal_recovery_stream.go +++ b/backend/internal/service/openai_refusal_recovery_stream.go @@ -25,7 +25,6 @@ const ( type openAIRefusalStreamState struct { matcher *OpenAIRefusalMatcher visibleText strings.Builder - matched bool passthrough bool earlyEligible bool earlyEmitted bool @@ -87,7 +86,6 @@ func (s *openAIRefusalStreamState) observe(eventType string, payload []byte) (op case "response.output_text.delta", "response.refusal.delta": _, _ = s.visibleText.WriteString(gjson.GetBytes(payload, "delta").String()) if matched, keyword := s.matcher.MatchLeadingParagraphs(s.visibleText.String()); matched { - s.matched = true s.matchedEvidence = newOpenAIRefusalEvidence(keyword, s.visibleText.String()) return s.startEarlyReplacement() } @@ -106,7 +104,6 @@ func (s *openAIRefusalStreamState) observe(eventType string, payload []byte) (op _, _ = s.visibleText.WriteString(text) } if matched, keyword := s.matcher.MatchLeadingParagraphs(s.visibleText.String()); matched { - s.matched = true s.matchedEvidence = newOpenAIRefusalEvidence(keyword, s.visibleText.String()) return s.startEarlyReplacement() } diff --git a/backend/internal/service/openai_refusal_recovery_stream_test.go b/backend/internal/service/openai_refusal_recovery_stream_test.go index 218e22a31d4d..0625a6f5d1e0 100644 --- a/backend/internal/service/openai_refusal_recovery_stream_test.go +++ b/backend/internal/service/openai_refusal_recovery_stream_test.go @@ -85,7 +85,7 @@ func TestOpenAIRefusalStreamKeepsCheckingAcrossSingleLineBreak(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) - require.True(t, state.matched) + require.Equal(t, "cannot", state.evidence().Keyword) } func TestOpenAIRefusalStreamKeepsCheckingAfterFirstParagraph(t *testing.T) { @@ -99,7 +99,7 @@ func TestOpenAIRefusalStreamKeepsCheckingAfterFirstParagraph(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) - require.False(t, state.matched) + require.Empty(t, state.evidence().Keyword) require.False(t, state.passthrough) action, _, observeErr = state.observe( @@ -108,7 +108,7 @@ func TestOpenAIRefusalStreamKeepsCheckingAfterFirstParagraph(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) - require.True(t, state.matched) + require.Equal(t, "不能", state.evidence().Keyword) } func TestOpenAIRefusalStreamPassesBeforeThirdParagraph(t *testing.T) { @@ -130,7 +130,7 @@ func TestOpenAIRefusalStreamPassesBeforeThirdParagraph(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamPass, action) - require.False(t, state.matched) + require.Empty(t, state.evidence().Keyword) } func TestOpenAIRefusalStreamFailsOpenAtRuneScanLimit(t *testing.T) { @@ -163,7 +163,7 @@ func TestOpenAIRefusalStreamMatchesStructuredRefusalEvents(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) - require.True(t, state.matched) + require.Equal(t, "不能", state.evidence().Keyword) action, _, observeErr = state.observe( "response.refusal.done", @@ -171,7 +171,7 @@ func TestOpenAIRefusalStreamMatchesStructuredRefusalEvents(t *testing.T) { ) require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) - require.True(t, state.matched) + require.Equal(t, "不能", state.evidence().Keyword) } func TestOpenAIRefusalStreamEmitsReplacementBeforeTerminalAndCompletesWithUsage(t *testing.T) { @@ -279,7 +279,7 @@ func TestOpenAIRefusalStreamRewritesNonEarlyEmptyTerminalFromCompletedMessage(t require.NoError(t, observeErr) require.Equal(t, openAIRefusalStreamHold, action) require.Nil(t, replacement) - require.True(t, state.matched) + require.Equal(t, "不能", state.evidence().Keyword) require.False(t, state.earlyEmitted) donePayload := fmt.Sprintf( diff --git a/backend/internal/service/openai_refusal_recovery_test.go b/backend/internal/service/openai_refusal_recovery_test.go index c008cfb5c589..9f80d4eb6bd6 100644 --- a/backend/internal/service/openai_refusal_recovery_test.go +++ b/backend/internal/service/openai_refusal_recovery_test.go @@ -33,11 +33,11 @@ func TestOpenAIRefusalRecoveryDisabledLoadsIndependentCyberSetting(t *testing.T) require.Equal(t, 1, getMultipleCalls) } -func TestOpenAIRefusalMatcherMatchesNormalizedFirstParagraph(t *testing.T) { +func TestOpenAIRefusalMatcherMatchesNormalizedLeadingParagraph(t *testing.T) { matcher, err := NewOpenAIRefusalMatcher([]string{"I'm unable", "不能"}, "继续当前任务") require.NoError(t, err) - matched, keyword := matcher.MatchFirstParagraph("I’m UNABLE to provide that.\n\nThe second paragraph is ignored.") + matched, keyword := matcher.MatchLeadingParagraphs("I’m UNABLE to provide that.\n\nA second paragraph follows.") require.True(t, matched) require.Equal(t, "I'm unable", keyword) } @@ -67,14 +67,6 @@ func TestOpenAIRefusalRequestAllowsEarlyStreamRewriteOnlyForTextOnlyRequests(t * } } -func TestOpenAIRefusalMatcherIgnoresLaterParagraphs(t *testing.T) { - matcher, err := NewOpenAIRefusalMatcher([]string{"policy"}, "继续当前任务") - require.NoError(t, err) - - matched, _ := matcher.MatchFirstParagraph("This is a normal answer.\n\nThe policy details follow.") - require.False(t, matched) -} - func TestOpenAIRefusalMatcherMatchesSecondLeadingParagraph(t *testing.T) { matcher, err := NewOpenAIRefusalMatcher([]string{"不能"}, "继续当前任务") require.NoError(t, err) @@ -108,10 +100,10 @@ func TestRewriteOpenAIResponsesJSONReplacesTextOnlyResponse(t *testing.T) { require.NoError(t, err) body := []byte(`{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I cannot help with that."}]}],"usage":{"input_tokens":9,"output_tokens":6,"total_tokens":15}}`) - rewritten, matched, keyword, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, evidence, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.True(t, matched) - require.Equal(t, "cannot", keyword) + require.Equal(t, "cannot", evidence.Keyword) require.Equal(t, "resp_1", gjsonString(t, rewritten, "id")) require.Equal(t, "继续当前任务", gjsonString(t, rewritten, "output.0.content.0.text")) require.Equal(t, float64(15), gjsonNumber(t, rewritten, "usage.total_tokens")) @@ -122,11 +114,11 @@ func TestRewriteOpenAIResponsesJSONReplacesStructuredRefusal(t *testing.T) { require.NoError(t, err) body := []byte(`{"id":"resp_refusal","object":"response","model":"gpt-5.6-sol","status":"completed","output":[{"id":"msg_refusal","type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":"不能协助绕过真实服务的付费或会员限制,包括破解订阅校验。"}]}],"usage":{"input_tokens":12,"output_tokens":18,"total_tokens":30}}`) - rewritten, matched, keyword, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, evidence, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.True(t, matched) - require.Equal(t, "不能", keyword) + require.Equal(t, "不能", evidence.Keyword) require.Equal(t, "output_text", gjsonString(t, rewritten, "output.0.content.0.type")) require.Equal(t, "继续当前任务", gjsonString(t, rewritten, "output.0.content.0.text")) require.Equal(t, float64(30), gjsonNumber(t, rewritten, "usage.total_tokens")) @@ -139,11 +131,11 @@ func TestRewriteOpenAIResponsesJSONReplacesScreenshotRefusalInSecondParagraph(t refusal := "可以协助分析你自有或明确授权的应用,例如会员鉴权安全测试、逆向协议、漏洞复现和修复建议。\n\n但不能帮助绕过第三方付费会员、伪造订阅状态或破解授权。若是授权测试,请提供 APK/安装包、源码或测试环境,以及具体测试目标。" body := []byte(fmt.Sprintf(`{"id":"resp_second_paragraph","object":"response","model":"gpt-5.6-sol","status":"completed","output":[{"id":"msg_refusal","type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":%q}]}],"usage":{"input_tokens":12,"output_tokens":42,"total_tokens":54}}`, refusal)) - rewritten, matched, keyword, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, evidence, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.True(t, matched) - require.Equal(t, "不能", keyword) + require.Equal(t, "不能", evidence.Keyword) require.Equal(t, "output_text", gjsonString(t, rewritten, "output.0.content.0.type")) require.Equal(t, "继续我们的任务", gjsonString(t, rewritten, "output.0.content.0.text")) require.NotContains(t, string(rewritten), "伪造订阅状态") @@ -154,7 +146,7 @@ func TestRewriteOpenAIResponsesJSONLeavesToolResponsesUntouched(t *testing.T) { require.NoError(t, err) body := []byte(`{"id":"resp_2","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I cannot continue."}]},{"type":"function_call","name":"shell","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`) - rewritten, matched, _, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, _, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.False(t, matched) require.JSONEq(t, string(body), string(rewritten)) @@ -165,7 +157,7 @@ func TestRewriteOpenAIResponsesJSONLeavesImageResponsesUntouched(t *testing.T) { require.NoError(t, err) body := []byte(`{"id":"resp_image","status":"completed","output":[{"id":"img_1","type":"image_generation_call","status":"completed","result":"base64"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I cannot help."}]}]}`) - rewritten, matched, _, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, _, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.False(t, matched) @@ -177,7 +169,7 @@ func TestRewriteOpenAIResponsesJSONLeavesIncompleteResponsesUntouched(t *testing require.NoError(t, err) body := []byte(`{"id":"resp_incomplete","status":"incomplete","output":[{"id":"msg_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"I cannot help."}]}]}`) - rewritten, matched, _, err := RewriteOpenAIResponsesJSON(body, matcher) + rewritten, matched, _, err := rewriteOpenAIResponsesJSONWithEvidence(body, matcher) require.NoError(t, err) require.False(t, matched) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 8194b6060134..1ca63816c389 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -44,7 +44,6 @@ const ( openAIWSStoreDisabledConnModeOff = "off" openAIWSIngressStagePreviousResponseNotFound = "previous_response_not_found" - openAIWSIngressStageInvalidEncryptedContent = "invalid_encrypted_content" openAIWSMaxPrevResponseIDDeletePasses = 8 ) diff --git a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go index 2905223479cd..523632d77cca 100644 --- a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go +++ b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go @@ -129,7 +129,7 @@ func TestForwardOpenAIWSV2_DisconnectedDrainPreservesTerminalAndCommitBoundaries result, err := svc.forwardOpenAIWSV2( ctx, c, account, map[string]any{"model": "gpt-5.5", "stream": true, "reasoning": map[string]any{"effort": "high"}, "input": "hello"}, - "", "sk-test", OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + "", "", "sk-test", OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, false, true, "gpt-5.5", "gpt-5.5", time.Now(), 1, "", new(bool), ) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 52755ff0f7af..4275f1d80a5d 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -19,29 +19,7 @@ import ( "go.uber.org/zap" ) -// forwardOpenAIWSV2 retains the pre-v0.2.5 call shape used by downstream tests. -// New callers should use forwardOpenAIWSV2WithScope to provide an execution scope. func (s *OpenAIGatewayService) forwardOpenAIWSV2( - ctx context.Context, - c *gin.Context, - account *Account, - reqBody map[string]any, - clientPromptCacheKey string, - token string, - decision OpenAIWSProtocolDecision, - isCodexCLI bool, - reqStream bool, - originalModel string, - mappedModel string, - startTime time.Time, - attempt int, - lastFailureReason string, - agentTaskRecoveryTried *bool, -) (*OpenAIForwardResult, error) { - return s.forwardOpenAIWSV2WithScope(ctx, c, account, reqBody, clientPromptCacheKey, "", token, decision, isCodexCLI, reqStream, originalModel, mappedModel, startTime, attempt, lastFailureReason, agentTaskRecoveryTried) -} - -func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( ctx context.Context, c *gin.Context, account *Account, diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go index c832410f0879..472be497bba1 100644 --- a/backend/internal/service/openai_ws_state_store.go +++ b/backend/internal/service/openai_ws_state_store.go @@ -108,7 +108,6 @@ const ( type OpenAIContinuationBindingLookup struct { State OpenAIContinuationBindingState AccountID int64 - Err error } func LookupOpenAIContinuationBinding(ctx context.Context, store OpenAIWSStateStore, groupID int64, responseID string) OpenAIContinuationBindingLookup { @@ -117,7 +116,7 @@ func LookupOpenAIContinuationBinding(ctx context.Context, store OpenAIWSStateSto } accountID, err := store.GetResponseAccount(ctx, groupID, responseID) if err != nil { - return OpenAIContinuationBindingLookup{State: OpenAIContinuationBindingStoreError, Err: err} + return OpenAIContinuationBindingLookup{State: OpenAIContinuationBindingStoreError} } if accountID <= 0 { return OpenAIContinuationBindingLookup{State: OpenAIContinuationBindingMiss} diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index cd83ca6893d1..9c1a49d21c1c 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -288,7 +288,6 @@ func TestLookupOpenAIContinuationBindingDistinguishesMissAndStoreError(t *testin miss := LookupOpenAIContinuationBinding(ctx, NewOpenAIWSStateStore(nil), groupID, "resp_miss") require.Equal(t, OpenAIContinuationBindingMiss, miss.State) - require.NoError(t, miss.Err) sentinelMiss := LookupOpenAIContinuationBinding( ctx, NewOpenAIWSStateStore(&openAIWSStateStoreTimeoutProbeCache{getErr: ErrStickySessionNotFound}), @@ -296,13 +295,11 @@ func TestLookupOpenAIContinuationBindingDistinguishesMissAndStoreError(t *testin "resp_sentinel_miss", ) require.Equal(t, OpenAIContinuationBindingMiss, sentinelMiss.State) - require.NoError(t, sentinelMiss.Err) storeErr := errors.New("redis unavailable") probe := &openAIWSStateStoreTimeoutProbeCache{getErr: storeErr} lookup := LookupOpenAIContinuationBinding(ctx, NewOpenAIWSStateStore(probe), groupID, "resp_store_error") require.Equal(t, OpenAIContinuationBindingStoreError, lookup.State) - require.ErrorIs(t, lookup.Err, storeErr) require.Zero(t, lookup.AccountID) } diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 053c3b05e16f..a6830cadc0ce 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -121,9 +121,6 @@ type OpsErrorLogFilter struct { // Optional correlation keys for exact matching. RequestID string ClientRequestID string - // ExcludeClientRequestIDPrefix drops rows whose client request ID starts - // with it (the user-facing list hides admin-only Image Studio rows). - ExcludeClientRequestIDPrefix string // User-scoped filters (used by the user-facing error requests endpoint and // by admin drill-down from the usage page). diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index afed559416a5..9beeca51f95d 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -728,9 +728,6 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi //(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。 filter.Phase = "" filter.IncludeRecoveredUpstream = false - // Image Studio gateway rows keep the upstream response for administrators; - // the Image Studio job itself shows the user its message. - filter.ExcludeClientRequestIDPrefix = ImageStudioClientRequestIDPrefix list, err := s.opsRepo.ListErrorLogs(ctx, filter) if err != nil { @@ -790,7 +787,7 @@ func (s *OpsService) GetUserErrorRequestDetail(ctx context.Context, userID, id i } // 归属只能由通过鉴权时写入的 user_id 确定。 ownedDirectly := detail.UserID != nil && *detail.UserID == userID - if !ownedDirectly || strings.HasPrefix(detail.ClientRequestID, ImageStudioClientRequestIDPrefix) { + if !ownedDirectly { return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found") } return ToUserErrorRequestDetail(detail), nil diff --git a/backend/internal/service/ops_service_user_error_test.go b/backend/internal/service/ops_service_user_error_test.go index 7119024fa36f..e92f149a5261 100644 --- a/backend/internal/service/ops_service_user_error_test.go +++ b/backend/internal/service/ops_service_user_error_test.go @@ -66,10 +66,6 @@ func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) { if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid { t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID) } - // Image Studio gateway rows stay admin-only. - if stub.gotFilter.ExcludeClientRequestIDPrefix != ImageStudioClientRequestIDPrefix { - t.Fatalf("Image Studio rows not excluded: %q", stub.gotFilter.ExcludeClientRequestIDPrefix) - } // 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效) if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" { t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase) @@ -167,17 +163,3 @@ func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) { t.Fatal("expected error for id=-5") } } - -// An Image Studio gateway row keeps the upstream response for administrators; -// its owner does not get it from the error-request detail endpoint. -func TestGetUserErrorRequestDetail_HidesImageStudioRows(t *testing.T) { - owner := int64(5) - stub := &stubOpsRepoForUserErr{detailToReturn: &OpsErrorLogDetail{OpsErrorLog: OpsErrorLog{ - ID: 9, UserID: &owner, StatusCode: 502, ClientRequestID: ImageStudioClientRequestIDPrefix + "job-1-item-2-0a1b2c3d4e5f", - }}} - svc := &OpsService{opsRepo: stub} - got, err := svc.GetUserErrorRequestDetail(context.Background(), owner, 9) - if got != nil || !infraerrors.IsNotFound(err) { - t.Fatalf("expected NotFound for an Image Studio row, got %+v, %v", got, err) - } -} diff --git a/backend/internal/service/policy_context.go b/backend/internal/service/policy_context.go deleted file mode 100644 index 7dc5cdf1e1ff..000000000000 --- a/backend/internal/service/policy_context.go +++ /dev/null @@ -1,28 +0,0 @@ -package service - -import "context" - -type policyCancellationSignalsKey struct{} - -// Policy cancellation is independent from a client disconnect. Upstream IO -// may detach from that disconnect, but must still stop when a policy signal fires. -func detachPolicyContext(parent context.Context) (context.Context, context.CancelFunc) { - signals, _ := parent.Value(policyCancellationSignalsKey{}).([]context.Context) - if len(signals) == 0 { - return context.WithoutCancel(parent), func() {} - } - ctx, cancel := context.WithCancel(context.WithoutCancel(parent)) - stops := make([]func() bool, 0, len(signals)) - for _, signal := range signals { - if signal.Err() != nil { - cancel() - } - stops = append(stops, context.AfterFunc(signal, cancel)) - } - return ctx, func() { - for _, stop := range stops { - stop() - } - cancel() - } -} diff --git a/backend/internal/service/policy_context_test.go b/backend/internal/service/policy_context_test.go deleted file mode 100644 index 47ae1eaea4eb..000000000000 --- a/backend/internal/service/policy_context_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestNativePolicyCancellationSurvivesDetachedClientContext(t *testing.T) { - client, cancelClient := context.WithCancel(context.Background()) - policy, cancelPolicy := context.WithCancel(context.Background()) - parent := context.WithValue(client, policyCancellationSignalsKey{}, []context.Context{policy}) - detached, release := detachPolicyContext(parent) - cancelClient() - require.NoError(t, detached.Err(), "a client disconnect does not terminate detached work") - cancelPolicy() - select { - case <-detached.Done(): - case <-time.After(time.Second): - t.Fatal("native policy cancellation did not reach detached work") - } - require.ErrorIs(t, detached.Err(), context.Canceled) - release() -} diff --git a/backend/internal/service/setting_image_tools.go b/backend/internal/service/setting_image_tools.go deleted file mode 100644 index f184632f1d8c..000000000000 --- a/backend/internal/service/setting_image_tools.go +++ /dev/null @@ -1,39 +0,0 @@ -package service - -import ( - "context" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// SettingKeyImageToolsConfig stores the Image Studio switch formerly kept in -// the image-tools plugin configuration. A missing switch is off. -const SettingKeyImageToolsConfig = "image_tools_config" - -// LoadImageToolsConfig installs the stored switch for this process at startup. -// Without a stored value the deploy-time flag stays in force. -func (s *SettingService) LoadImageToolsConfig(ctx context.Context) error { - var stored extensionv1.ImageToolsConfig - found, err := s.readNativeSwitchSetting(ctx, SettingKeyImageToolsConfig, &stored, "studio_enabled") - if err != nil { - return err - } - if found { - ConfigureImageTools(&stored) - } - return nil -} - -// UpdateImageToolsConfig persists the switch and applies it to this process, -// starting the Image Studio runtime when it is switched on. -func (s *SettingService) UpdateImageToolsConfig(ctx context.Context, config extensionv1.ImageToolsConfig) error { - if err := s.writeJSONSetting(ctx, SettingKeyImageToolsConfig, config); err != nil { - return err - } - ConfigureImageTools(&config) - // image_studio_enabled is a public setting embedded in the served HTML. - if s.onUpdate != nil { - s.onUpdate() - } - return nil -} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 3d2e14d20ae3..ebeec873efc7 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -365,7 +365,6 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorHideUserRanking: isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - ImageStudioEnabled: ImageStudioFeatureEnabled(), FlatThemeEnabled: FlatThemeEnabled(), SubscriptionEnabled: !isFalseSettingValue(settings[SettingKeySubscriptionEnabled]), @@ -638,7 +637,6 @@ type PublicSettingsInjectionPayload struct { // from non-admin channel-monitor v2 viewers; default false (visible). ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ImageStudioEnabled bool `json:"image_studio_enabled"` FlatThemeEnabled bool `json:"flat_theme_enabled"` SubscriptionEnabled bool `json:"subscription_enabled"` ModelPlazaEnabled bool `json:"model_plaza_enabled"` @@ -724,7 +722,6 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ImageStudioEnabled: settings.ImageStudioEnabled, FlatThemeEnabled: settings.FlatThemeEnabled, SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 81ee43d72452..8682fb891c81 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -407,7 +407,6 @@ type PublicSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ImageStudioEnabled bool `json:"image_studio_enabled"` FlatThemeEnabled bool `json:"flat_theme_enabled"` // Subscription feature switch (see SystemSettings.SubscriptionEnabled) diff --git a/backend/internal/service/usage_billing.go b/backend/internal/service/usage_billing.go index 8b28b01ba60c..e058ecb1b029 100644 --- a/backend/internal/service/usage_billing.go +++ b/backend/internal/service/usage_billing.go @@ -164,7 +164,6 @@ type AccountQuotaState struct { type UsageBillingApplyResult struct { Applied bool - OwnerAccountID int64 APIKeyQuotaExhausted bool NewBalance *float64 // post-deduction balance (nil = no balance deduction) BalanceOverdrafted bool // true when the sufficient-balance guard missed and debt was still recorded diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index daeb59d613b5..3593fd001497 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "os" - "path/filepath" "time" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -218,14 +217,6 @@ func ProvideBatchImageCleanupService(repo BatchImageRepository, accountRepo Acco return svc } -func ProvideImageStudioArtifactStore(cfg *config.Config, repo ImageStudioRepository) *ImageStudioArtifactStore { - root := "" - if cfg != nil { - root = filepath.Join(cfg.Pricing.DataDir, "image-studio") - } - return NewImageStudioArtifactStore(root, repo) -} - // ProvideOpenAIOAuthService creates OpenAIOAuthService with privacy/account enrichment support. func ProvideOpenAIOAuthService( proxyRepo ProxyRepository, @@ -936,9 +927,9 @@ func ProvideOpsIngressRejectAggregator(opsRepo OpsRepository, opsService *OpsSer // ProvideSettingService wires SettingService with group reader and proxy repo. func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config, _ *NativeFeatureBootstrap) (*SettingService, error) { svc := NewSettingService(settingRepo, cfg) - // Image tool, observability and Codex request compression switches are read - // by package-level gates before Image Studio and the gateway start. - for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { + // Observability and Codex request compression switches are read by + // package-level gates before the gateway starts. + for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { if err := load(context.Background()); err != nil { return nil, err } @@ -1038,9 +1029,6 @@ var ProviderSet = wire.NewSet( NewBatchImageDownloadService, ProvideBatchImageCleanupService, ProvideBatchImageWorkerRuntime, - ProvideImageStudioArtifactStore, - wire.Bind(new(ImageStudioFileStorage), new(*ImageStudioArtifactStore)), - NewImageStudioService, wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)), NewOAuthService, ProvideClaudeResetCreditService, diff --git a/backend/internal/testextensions/fixtures.go b/backend/internal/testextensions/fixtures.go deleted file mode 100644 index 2ddff6a126bc..000000000000 --- a/backend/internal/testextensions/fixtures.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package testextensions installs actual independent modules as deterministic -// contract fixtures. It is imported only by host tests; production composes -// native modules; the remaining plugin manager serves third-party packages. -package testextensions - -import ( - "context" - "strings" - - accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type operations struct{} - -func (operations) InvokeOperation(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if strings.HasPrefix(in.Operation, "taxonomy.") || strings.HasPrefix(in.Operation, "test.") || strings.HasPrefix(in.Operation, "import.") { - return accounttools.New().Invoke(ctx, in) - } - return extensionv1.Result{}, service.ErrExtensionOperationDisabled -} -func Install() { - service.ConfigureNativePolicyOperations(operations{}) - service.ConfigureImageTools(nil) - service.ConfigureAdminObservability(nil) -} - -func InstallImageTools(config extensionv1.ImageToolsConfig) { - service.ConfigureNativePolicyOperations(operations{}) - service.ConfigureImageTools(&config) -} diff --git a/backend/migrations/270_drop_image_studio.sql b/backend/migrations/270_drop_image_studio.sql new file mode 100644 index 000000000000..d967c00e6a8b --- /dev/null +++ b/backend/migrations/270_drop_image_studio.sql @@ -0,0 +1,26 @@ +-- Remove what Image Studio left stored. The feature is deleted and the +-- application reads nothing this migration removes. A new database only loses +-- the empty tables of 2. +-- +-- 1. settings.image_tools_config, the Image Studio switch. +-- 2. image_studio_artifacts, image_studio_items and image_studio_jobs (233), +-- children first, with their rows, indexes, constraints and sequences: the +-- job history with its prompts and the records of the stored image files. +-- The foreign keys from image_studio_jobs to users and api_keys go with the +-- table and no row of users or api_keys is written, but removing them locks +-- both tables exclusively until the migration commits. The tables are +-- therefore dropped last, and lock_timeout bounds the wait for that lock. +-- +-- Usage logs, Ops error logs, audit logs and plugin installations are not +-- touched. The image files Image Studio wrote under /image-studio +-- are not in the database and stay on disk. Every statement is a no-op once +-- its row or table is gone, so the migration can run again. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +DELETE FROM settings +WHERE key = 'image_tools_config'; + +DROP TABLE IF EXISTS image_studio_artifacts; +DROP TABLE IF EXISTS image_studio_items; +DROP TABLE IF EXISTS image_studio_jobs; diff --git a/backend/migrations/271_drop_usage_billing_dedup_account_id.sql b/backend/migrations/271_drop_usage_billing_dedup_account_id.sql new file mode 100644 index 000000000000..3825c744ed38 --- /dev/null +++ b/backend/migrations/271_drop_usage_billing_dedup_account_id.sql @@ -0,0 +1,25 @@ +-- Drop usage_billing_dedup.account_id. +-- +-- 238 added the column for the Cindy account statistics: every billing claim +-- stored the account that first settled the request. Those statistics went +-- with Cindy (260 dropped accounts.cindy_account_stats_reset_at, the other half +-- of 238). Nothing reads the column and the billing claim no longer writes it. +-- +-- Only this column goes, with its values. No row is added or removed; the +-- unique claim on (request_id, api_key_id), the fingerprint and created_at are +-- untouched, and usage_billing_dedup_archive never had the column. Dropping a +-- column rewrites no row but holds the table's exclusive lock until the +-- migration commits; lock_timeout bounds the wait for that lock. +-- +-- There is no down path. Earlier images name the column in the insert of every +-- billing claim, so on a database without it their usage billing fails; going +-- back to one of them needs the column added again first +-- (ALTER TABLE usage_billing_dedup ADD COLUMN account_id BIGINT). +-- +-- The statement is a no-op once the column is gone, so the migration can run +-- again. No explicit BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +ALTER TABLE usage_billing_dedup + DROP COLUMN IF EXISTS account_id; diff --git a/backend/migrations/image_studio_jobs_migration_test.go b/backend/migrations/image_studio_jobs_migration_test.go deleted file mode 100644 index ab4dc4c34110..000000000000 --- a/backend/migrations/image_studio_jobs_migration_test.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build unit - -package migrations - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestMigration233CreatesOnlySlimImageStudioTables(t *testing.T) { - // Migration identity is the complete filename. Official and downstream - // migrations intentionally share numeric prefixes. - raw, err := FS.ReadFile("233_image_studio_jobs.sql") - require.NoError(t, err) - sql := strings.ToLower(string(raw)) - - for _, table := range []string{ - "image_studio_jobs", - "image_studio_items", - "image_studio_artifacts", - } { - require.Contains(t, sql, "create table if not exists "+table) - } - require.Equal(t, 3, strings.Count(sql, "create table if not exists image_studio_")) - require.Contains(t, sql, "api_key_id bigint not null references api_keys(id)") - require.Contains(t, sql, "user_id bigint not null references users(id)") - require.Contains(t, sql, "where status in ('pending', 'preparing', 'running')") - require.Contains(t, sql, "count between 1 and 4") - require.Contains(t, sql, "expires_at timestamptz not null") - - for _, forbidden := range []string{ - "worker_lease", - "execution_reservation", - "reconciliation", - "receipt", - "effect_ledger", - "outbox", - "advisory", - "ambiguity", - } { - require.NotContains(t, sql, forbidden) - } -} diff --git a/frontend/package.json b/frontend/package.json index 1e99e85206c9..510361aeeb55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,6 @@ "autoprefixer": "^10.4.16", "eslint": "^8.57.0", "eslint-plugin-vue": "^9.25.0", - "fake-indexeddb": "^6.2.2", "jsdom": "^24.1.3", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fd9a5b034956..4b093d2196d3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -114,9 +114,6 @@ importers: eslint-plugin-vue: specifier: ^9.25.0 version: 9.33.0(eslint@8.57.1) - fake-indexeddb: - specifier: ^6.2.2 - version: 6.2.5 jsdom: specifier: ^24.1.3 version: 24.1.3 @@ -2472,10 +2469,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fake-indexeddb@6.2.5: - resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} - engines: {node: '>=18'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7175,8 +7168,6 @@ snapshots: extend@3.0.2: {} - fake-indexeddb@6.2.5: {} - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 63d87b474098..e8be297d0dbd 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1140,21 +1140,6 @@ export interface OfficialModelCapacityCatalog { entries: OfficialModelCapacityEntry[] } -export interface ImageToolsSettings { - studio_enabled: boolean -} - -/** Image Studio switch. */ -export async function getImageToolsSettings(): Promise { - const { data } = await apiClient.get("/admin/settings/image-tools"); - return data; -} - -export async function updateImageToolsSettings(settings: ImageToolsSettings): Promise { - const { data } = await apiClient.put("/admin/settings/image-tools", settings); - return data; -} - export interface ObservabilitySettings { telemetry_enabled: boolean theme_enabled: boolean @@ -1694,8 +1679,6 @@ export async function resetWebSearchUsage(payload: { export const settingsAPI = { getSettings, getOfficialModelCapacityCatalog, - getImageToolsSettings, - updateImageToolsSettings, getObservabilitySettings, updateObservabilitySettings, updateSettings, diff --git a/frontend/src/components/admin/ImageToolsSettingsPanel.vue b/frontend/src/components/admin/ImageToolsSettingsPanel.vue deleted file mode 100644 index b52d450f8286..000000000000 --- a/frontend/src/components/admin/ImageToolsSettingsPanel.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/frontend/src/components/icons/lucide.ts b/frontend/src/components/icons/lucide.ts index dcec0bab5d3a..7c28278e1fbe 100644 --- a/frontend/src/components/icons/lucide.ts +++ b/frontend/src/components/icons/lucide.ts @@ -224,7 +224,7 @@ export const lucideIcons = { brain: glyph('brain'), // by name (the Heroicons glyph was a flask stand-in) // layout chrome (AppSidebar / AppHeader; ui-el icons/ICONS.md §5) dashboard: glyph('layout-dashboard'), - camera: glyph('images'), // the batch-image and image-studio entries: pictures, by function + camera: glyph('images'), // the batch-image entry: pictures, by function folder: glyph('folder'), layers: glyph('layers'), // the reference's channel glyph coins: glyph('coins'), // the recharge / subscribe entry (upstream draws a custom coin mark) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 7a97275ba225..e74b96c0ff39 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -101,7 +101,7 @@ -