diff --git a/.downstream/gpt6-routing-and-prompts.md b/.downstream/gpt6-routing-and-prompts.md index 0d639344c34c..036d157b2efe 100644 --- a/.downstream/gpt6-routing-and-prompts.md +++ b/.downstream/gpt6-routing-and-prompts.md @@ -39,9 +39,9 @@ API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字 ## 提示词规则与账号选择 -系统提示词页面使用一个编辑器管理正文、角色、位置、平台/模型范围和默认选择。“保存并生效”在一个事务中创建不可变内容版本、更新规则和开关,只递增一次 revision。历史版本、来源同步和远程 Skill 位于高级入口;不存在第二个“设为全局当前模板”的生效步骤。 +从侧栏“扩展功能 → 系统提示词”打开编辑器,选择提示词、修改正文与投递范围,再点击“保存并生效”。页面只保留核心配置和当前提示词的简易历史;独立模板库、来源同步、Skill 发布和请求 JSON 调试已退出管理流程,旧入口在管理员鉴权后返回 HTTP 410。 -管理员 `GET /api/v1/admin/system-prompts/config` 返回配置、按规则 ID 索引的正文和协议能力。`PUT` 提交 `expected_revision`、运行开关、v2 `policy` 及仅包含改动正文的 `contents`。新规则可以携带零模板/版本 ID 和正文,也可以明确引用已有不可变版本;事务内生成真实引用。固定版本的旧规则 ID、默认选择、账号引用和顺序保留,v1 写入及无目标规则的旧发布操作明确拒绝。 +管理员 `GET /api/v1/admin/system-prompts/config` 返回配置、按规则 ID 索引的正文和协议能力。`PUT` 提交 `expected_revision`、运行开关、v2 `policy` 及仅包含改动正文的 `contents`,一次事务生成不可变内容版本并更新配置,只递增一次 revision。新规则携带零模板/版本 ID 和正文;历史恢复按当前规则校验,仍加入草稿并通过同一次保存生效。`GET /api/v1/admin/system-prompts/rules/:rule_id/history` 只读取该规则的历史。未展示的 Compact、回显兼容值省略时沿用当前值。 | 位置 | 含义 | | --- | --- | @@ -62,14 +62,34 @@ v2 使用独立 `role=auto/system/developer` 与 `position`。平台必须显式 账号详情和账号批量操作提供 `inherit / off / custom`:继承默认规则、关闭、或用指定规则替换默认集合。默认集合留空、指定账号选择 custom,即可只对指定账号开启。账号仅存规则引用,不复制正文;shadow 使用自身绑定。 -预览接受未保存正文和规则,使用实际协议转换与同一个编排器,展示基础指令、客户指令、最终角色/载体/索引及跳过原因,不访问模型上游。OAuth 身份参数等模拟部分会标明。实际转发先转换协议和准备基础指令,再应用规则、生成缓存标识/签名并发送;每次重试从干净输入构建,WS 历史累计器不保存本站注入。每轮冻结内容快照,换账号时重新解析绑定;规则变化隔离缓存,保留续接标识及精确载体回显保护。 +实际转发先转换协议和准备基础指令,再应用规则、生成缓存标识/签名并发送;每次重试从干净输入构建,WS 历史累计器不保存本站注入。每轮冻结内容快照,换账号时重新解析绑定;规则变化隔离缓存,保留续接标识及精确载体回显保护。编辑器直接根据后端协议能力约束可选角色、位置和范围。 迁移 `255_prompt_rule_policy_v2.sql` 固定旧 follow_active 当时的真实版本,并将旧各域开关折入相应规则,保留 OpenAI/Cindy 范围,避免自动扩散到新平台。Claude 旧自定义块保留布局、缓存属性、模型条件及单次占位符展开,统一投递;Settings 页的旧正文字段仅保留核对,修改请求返回迁移提示。单文档保留 64 KiB 上限;配置最多 64 条规则以容纳旧两个域的并集,每个实际请求命中的编译正文仍限制为 256 KiB,互斥平台的配置不相加计算请求预算。 -远程 Skill 仍以共享 `/current` 树和提示词成对发布。订阅该来源的所有规则都是明确影响集合;发布/回滚携带完整 `target_rule_ids` 和 `expected_config_revision`,与 registry revision 一起在事务内比较。普通来源同步只创建候选,应用历史正文只更新指定规则。来源不可用不会阻止无关内联规则,也不妨碍关闭总开关;命中不可用来源的请求明确失败。 +迁移 `256_prompt_independent_content.sql` 提供独立内容及冻结文件存储,应用启动时在同一事务中核验并固定升级前实际生效的正文和完整公共文件集合。规则 ID、顺序、默认选择、范围和账号引用保持;混合 Skill 使用活动配对的有效正文,不能用模板存档代替。迁移不联网、不初始化种子,缺少有效正文或活动文件时不提交部分结果,并使应用初始化失败;完成后重启不重复迁移。 + +已有 `/skills/security-research/current/` 链接继续读取冻结文件,不再跟随来源更新。旧数据保留;需要其他文件包的旧 Skill 版本只能查看档案,不能恢复文件包。普通正文和 Claude 结构化版本可恢复;结构化文本编辑保留块顺序和缓存属性。迁入的公开 Skill 版本保留原回显语义,编辑后创建的普通新版本默认私有,全局回显兼容值不因此改变。 配置冲突保留编辑器草稿,重新读取后可合并未编辑的远端字段。账号仍用自身版本比较;被账号引用的规则不能直接删除。 +## 292 采集代理输入 + +在“Codex 路由设置”的采集代理输入框粘贴地址。支持完整代理 URI、主机端口、两种顺序的四段认证格式、引用分列、中英文标签以及多条记录;IPv6 使用方括号。协议选择仅用于输入未写协议的情况,明确的 URI 或标签协议不会被覆盖。 + +例如 `proxy.example:8080:user:p@ss` 按候选解析后选择对应端点;`node.example:8080:user.example:9090` 可能有两种合法解释,需要从脱敏列表中明确选择。多个格式识别出同一端点及认证信息时去重,不同密码不合并。输入中的 `%40` 在 URI 认证字段中解码一次,在原始四段式或标签值中保持字面量;含分隔符或边界空白的列值使用引号。 + +`POST /api/v1/admin/settings/openai-codex-ticket/proxy-parse` 只解析候选,不存储输入、不连接网络。保存和测试携带原文、缺省协议及可选 `proxy_selection_id`,服务端重新解析并核对选择,最终仅保存一个规范代理地址。修改输入、缺省协议或管理员会话会失效旧选择和测试结果;格式错误、未选择或选择过期不会发起连接。测试和实际采集使用同一选定端点,保持证书信任及采集连接隔离。 + +## 批量账号连接测试 + +选择账号后即可开始,默认逐账号自动选择支持文本对话的模型;指定模型、推理强度和测试文本在设置中展开。批量请求的 `items` 接受 `selection_mode=auto/explicit`;省略模式且已有 `model_id` 的旧请求视为显式指定,否则默认自动选择。 + +后台逐项读取统一连接测试计划,目录失败或没有适用模型只影响该账号。发送前保存实际模型和必要执行信息,已解析的失败项重试沿用原模型;能力或映射变化时明确要求重新配置。沿用共享并发 5、每项 90 秒和 24 小时请求资料有效期,100 仅是目录及任务分块大小。 + +连接成功要求可展示的非空文本和有效协议终态,不判断答案质量。空流、提前 EOF、纯推理或失败终态不能显示成功;正常拒绝文本及带有效文本的输出额度结束可以证明连接完成。列表显示模型、耗时、明确失败类别和恢复结果,不保存完整回答或原始上游错误。 + +成功后恢复可恢复的错误、限流和临时不可调度状态,保留手动停用。恢复失败单独提示,连接成功状态不回退,也不会因重试而再次发出已成功的模型请求。关闭窗口表示收起;停止后只可重试已失败且资料仍有效的项,已取消和未执行项不会自动重放。 + ## 界面与验证边界 公共插件 context 的同值刷新不再重建 iframe。主题按 URL/变量差异更新,新 CSS 及字体准备完成再替换旧外观;加载失败保留最后成功版本。内容测量按帧合并,使用自然尺寸而非随滚动变化的 viewport 坐标。 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 440d89fc1803..725ac77edec6 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -158,6 +158,7 @@ jobs: src/features/image-studio/history.legacy.spec.ts \ src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \ src/components/admin/account/__tests__/BatchTestAccountModal.plan.spec.ts \ + src/components/admin/codex/__tests__/CodexRuntime.proxy.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 \ diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 11a499ddf6c3..25bf69d521e7 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -146,7 +146,6 @@ func provideCleanup( promptAudit *securityaudit.PromptService, promptDomain *service.PromptDomainRuntime, businessPrompt *service.BusinessSystemPromptService, - remoteSkillRegistry *service.RemoteSkillRegistryService, accountJobs *service.AccountJobRuntime, cindyHealth *service.CindyHealthService, cindyBalanceProbe *service.CindyBalanceProbeService, @@ -198,12 +197,6 @@ func provideCleanup( } return nil }}, - {"RemoteSkillRegistryService", func() error { - if remoteSkillRegistry != nil { - remoteSkillRegistry.Stop() - } - return nil - }}, {"BusinessSystemPromptService", func() error { if businessPrompt != nil { businessPrompt.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 658d83bf3034..6df3da9d63bd 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -156,15 +156,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService) businessSystemPromptStore := repository.NewBusinessSystemPromptRepository(db) businessSystemPromptRevisionBus := repository.NewBusinessSystemPromptRevisionBus(redisClient) - remoteSkillRegistryStore := repository.NewRemoteSkillRegistryRepository(db) - remoteSkillRegistryRevisionBus := repository.NewRemoteSkillRegistryRevisionBus(redisClient) remoteSkillRegistryFiles := service.ProvideRemoteSkillRegistryFiles() - remoteSkillCandidateSource := service.ProvideRemoteSkillCandidateSource() - remoteSkillRegistryService, err := service.ProvideRemoteSkillRegistryService(remoteSkillRegistryStore, remoteSkillRegistryRevisionBus, remoteSkillRegistryFiles, remoteSkillCandidateSource) - if err != nil { - return nil, err - } - businessSystemPromptService, err := service.ProvideBusinessSystemPromptService(businessSystemPromptStore, businessSystemPromptRevisionBus, remoteSkillRegistryService, remoteSkillRegistryRevisionBus, accountRepository, configConfig, settingService) + frozenPromptFiles := service.ProvideFrozenPromptFiles(businessSystemPromptStore, remoteSkillRegistryFiles) + businessSystemPromptService, err := service.ProvideBusinessSystemPromptService(businessSystemPromptStore, businessSystemPromptRevisionBus, frozenPromptFiles, accountRepository, configConfig, settingService) if err != nil { return nil, err } @@ -318,7 +312,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogRepository := repository.NewAuditLogRepository(db) auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) - systemPromptHandler := admin.NewSystemPromptHandler(businessSystemPromptService, remoteSkillRegistryService) + systemPromptHandler := admin.NewSystemPromptHandler(businessSystemPromptService) cindyBalanceProbeService := service.ProvideCindyBalanceProbeService(cindyBalanceProbeRepository, accountRepository, openAIGatewayService, rateLimitService) cindyBalanceProbeHandler := admin.NewCindyBalanceProbeHandler(cindyBalanceProbeService) accountJobRuntime, err := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) @@ -365,7 +359,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { imageStudioArtifactStore := service.ProvideImageStudioArtifactStore(configConfig, imageStudioRepository) imageStudioService := service.NewImageStudioService(imageStudioRepository, apiKeyRepository, accountRepository, imageStudioArtifactStore) imageStudioJobHandler := handler.NewImageStudioJobHandler(imageStudioService) - remoteSkillHandler := handler.ProvideRemoteSkillHandler(remoteSkillRegistryService) + remoteSkillHandler := handler.ProvideRemoteSkillHandler(frozenPromptFiles) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) 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, remoteSkillHandler, idempotencyCoordinator, idempotencyCleanupService) @@ -382,7 +376,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { if err != nil { return nil, err } - promptDomainRuntime := service.NewPromptDomainRuntime(remoteSkillRegistryService, businessSystemPromptService) + promptDomainRuntime := service.NewPromptDomainRuntime(businessSystemPromptService) codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) nativeCodexRepository := repository.NewNativeCodexRepository(db, accountRepository) nativeCodexRuntime := service.ProvideNativeCodexRuntime(openAIGatewayService, nativeCodexRepository, settingRepository, secretEncryptor, configConfig, nativeFeatureBootstrap, quotaActivityService) @@ -408,7 +402,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) cindyHealthRepository := repository.NewCindyHealthRepository(db) cindyHealthService := service.ProvideCindyHealthService(accountRepository, accountCredentialIdentityRepository, cindyHealthRepository, gatewayCache, openAIGatewayService, gatewayService) - v := provideCleanup(client, redisClient, 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, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, promptDomainRuntime, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime, pluginManager, nativeCodexRuntime) + v := provideCleanup(client, redisClient, 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, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, promptDomainRuntime, businessSystemPromptService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime, pluginManager, nativeCodexRuntime) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -512,7 +506,6 @@ func provideCleanup( promptAudit *securityaudit.PromptService, promptDomain *service.PromptDomainRuntime, businessPrompt *service.BusinessSystemPromptService, - remoteSkillRegistry *service.RemoteSkillRegistryService, accountJobs *service.AccountJobRuntime, cindyHealth *service.CindyHealthService, cindyBalanceProbe *service.CindyBalanceProbeService, @@ -563,12 +556,6 @@ func provideCleanup( } return nil }}, - {"RemoteSkillRegistryService", func() error { - if remoteSkillRegistry != nil { - remoteSkillRegistry.Stop() - } - return nil - }}, {"BusinessSystemPromptService", func() error { if businessPrompt != nil { businessPrompt.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 2ab7a8988d89..124e728c8fb3 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -110,7 +110,6 @@ func minimalDependencyCleanup(autoReset *service.OpenAIQuotaAutoResetService) fu nil, // promptAudit nil, // promptDomain nil, // businessPrompt - nil, // remoteSkillRegistry nil, // accountJobRuntime nil, // cindyHealth nil, // cindyBalanceProbe diff --git a/backend/internal/accounttools/policy/batch.go b/backend/internal/accounttools/policy/batch.go index 2bf6fdd22083..39d956c9bad1 100644 --- a/backend/internal/accounttools/policy/batch.go +++ b/backend/internal/accounttools/policy/batch.go @@ -16,19 +16,32 @@ func planBatch(request extensionv1.BatchTestPlanningRequest) (extensionv1.BatchT plan := extensionv1.BatchTestPlan{Models: make(map[int64]string)} if request.HasItems || request.Items != nil { efforts := map[int64]string{} + modes := map[int64]string{} for _, item := range request.Items { + if item.ModelID != "" && strings.TrimSpace(item.ModelID) == "" { + return plan, invalid + } item.ModelID = strings.TrimSpace(item.ModelID) - if item.AccountID <= 0 || item.ModelID == "" || len(item.ModelID) > 256 || len(item.ReasoningEffort) > 32 || item.ReasoningEffort != strings.TrimSpace(item.ReasoningEffort) { + if item.SelectionMode == "" { + item.SelectionMode = "auto" + if item.ModelID != "" { + item.SelectionMode = "explicit" + } + } + if item.AccountID <= 0 || (item.SelectionMode != "auto" && item.SelectionMode != "explicit") || + (item.SelectionMode == "explicit" && item.ModelID == "") || (item.SelectionMode == "auto" && item.ModelID != "") || + len(item.ModelID) > 256 || len(item.ReasoningEffort) > 32 || item.ReasoningEffort != strings.TrimSpace(item.ReasoningEffort) { return plan, invalid } if previous, exists := plan.Models[item.AccountID]; exists { - if previous != item.ModelID || efforts[item.AccountID] != item.ReasoningEffort { + if previous != item.ModelID || efforts[item.AccountID] != item.ReasoningEffort || modes[item.AccountID] != item.SelectionMode { return plan, invalid } continue } plan.AccountIDs = append(plan.AccountIDs, item.AccountID) plan.Models[item.AccountID], efforts[item.AccountID] = item.ModelID, item.ReasoningEffort + modes[item.AccountID] = item.SelectionMode plan.Items = append(plan.Items, item) } } else { diff --git a/backend/internal/codexruntime/tickets/module.go b/backend/internal/codexruntime/tickets/module.go index 5e59e9a09f3a..21281dd26603 100644 --- a/backend/internal/codexruntime/tickets/module.go +++ b/backend/internal/codexruntime/tickets/module.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "slices" "strconv" "strings" @@ -24,13 +23,14 @@ import ( ) type Config struct { - RoutingSchema int `json:"routing_schema"` - Enabled bool `json:"enabled"` - FailClosed bool `json:"fail_closed"` - ProxyURL string `json:"proxy_url"` - ProxyProtocol string `json:"proxy_protocol,omitempty"` - Models []string `json:"models"` - RequestZstd bool `json:"request_zstd"` + RoutingSchema int `json:"routing_schema"` + Enabled bool `json:"enabled"` + FailClosed bool `json:"fail_closed"` + ProxyURL string `json:"proxy_url"` + ProxyProtocol string `json:"proxy_protocol,omitempty"` + ProxySelectionID string `json:"proxy_selection_id,omitempty"` + Models []string `json:"models"` + RequestZstd bool `json:"request_zstd"` } type HostCaller interface { @@ -67,7 +67,7 @@ func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.Ra } for key, value := range fields { switch key { - case "enabled", "fail_closed", "proxy_url", "proxy_protocol", "models", "request_zstd", "routing_schema": + case "enabled", "fail_closed", "proxy_url", "proxy_protocol", "proxy_selection_id", "models", "request_zstd", "routing_schema": if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { return nil, errors.New("null codex runtime setting") } @@ -96,14 +96,13 @@ func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.Ra } seen[model] = true } - if cfg.ProxyURL != "" { - normal, err := normalizeProxyForm(cfg.ProxyURL, cfg.ProxyProtocol) - if err != nil { - return nil, errors.New("invalid ticket proxy") - } - cfg.ProxyURL = normal + normal, err := proxytransport.Resolve(cfg.ProxyURL, cfg.ProxyProtocol, cfg.ProxySelectionID) + if err != nil { + return nil, err } + cfg.ProxyURL = normal cfg.ProxyProtocol = "" + cfg.ProxySelectionID = "" return json.Marshal(cfg) } @@ -337,17 +336,18 @@ func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensi value = rules case in.Capability == extensionv1.CapabilityAdmin && in.Operation == "proxy.test": var req struct { - ProxyURL string `json:"proxy_url"` - Protocol string `json:"protocol"` + ProxyURL string `json:"proxy_url"` + Protocol string `json:"protocol"` + ProxySelectionID string `json:"proxy_selection_id"` } if json.Unmarshal(in.Payload, &req) != nil { return extensionv1.Result{}, errors.New("invalid proxy test") } testCtx, testCancel := context.WithTimeout(ctx, 25*time.Second) defer testCancel() - normal, normalizeErr := normalizeProxyForm(req.ProxyURL, req.Protocol) + normal, normalizeErr := proxytransport.Resolve(req.ProxyURL, req.Protocol, req.ProxySelectionID) if normalizeErr != nil { - return extensionv1.Result{}, errors.New("invalid proxy input") + return extensionv1.Result{}, normalizeErr } client, result, testErr := m.prepareProxy(testCtx, host, normal, true) if client != nil { @@ -434,22 +434,6 @@ func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensi return extensionv1.Result{Payload: raw}, err } -func normalizeProxyForm(raw, protocol string) (string, error) { - normal, err := proxytransport.Normalize(raw) - if err != nil || normal == "" { - return normal, err - } - if protocol != "" { - if !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, protocol) { - return "", errors.New("invalid proxy protocol") - } - address, _ := url.Parse(normal) - address.Scheme = protocol - normal = address.String() - } - return normal, nil -} - func stateKey(accountID int64, model string) string { sum := sha256.Sum256([]byte(model)) return strconv.FormatInt(accountID, 10) + "." + hex.EncodeToString(sum[:]) diff --git a/backend/internal/codexruntime/tickets/proxy.go b/backend/internal/codexruntime/tickets/proxy.go index e4ed297fac28..80ff51036703 100644 --- a/backend/internal/codexruntime/tickets/proxy.go +++ b/backend/internal/codexruntime/tickets/proxy.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "net/http/httptrace" - "net/url" "sync" "time" @@ -93,7 +92,7 @@ func verifyProxyCertificate(state tls.ConnectionState, trust *ProxyTrust, learn } func ticketProxyTransport(raw string, trust *ProxyTrust, learn bool, observe func(*ProxyTrust)) (*http.Transport, error) { - address, err := url.Parse(raw) + address, err := proxytransport.ParseEndpoint(raw) if err != nil { return nil, errors.New("invalid proxy") } @@ -132,12 +131,12 @@ func loadProxyTrust(ctx context.Context, host HostCaller, raw string) (*ProxyTru } func prepareProxy(ctx context.Context, host HostCaller, raw string, explicitTest bool) (*http.Client, *ProxyResult, error) { - normal, err := proxytransport.Normalize(raw) + address, err := proxytransport.ParseEndpoint(raw) result := &ProxyResult{Stages: []ProxyStage{}, Code: "invalid_proxy"} - if err != nil || normal == "" { + if err != nil { return nil, result, errors.New("invalid proxy") } - address, _ := url.Parse(normal) + normal := raw result.Protocol = address.Scheme trust, revision, err := loadProxyTrust(ctx, host, normal) if err != nil { diff --git a/backend/internal/codexruntime/tickets/proxy_selection_test.go b/backend/internal/codexruntime/tickets/proxy_selection_test.go new file mode 100644 index 000000000000..3b1b7807f3dd --- /dev/null +++ b/backend/internal/codexruntime/tickets/proxy_selection_test.go @@ -0,0 +1,67 @@ +package tickets + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/proxytransport" +) + +func TestProxySelectionSaveAndTestUseIdenticalEndpoint(t *testing.T) { + m := NewModule() + defer m.cancel() + m.SetHost(&memoryHost{}) + input := "http://u:one@first.example:80\nsocks5://u:two@[::1]:1080" + parsed := proxytransport.Parse(input, "https") + if len(parsed.Candidates) != 2 { + t.Fatal("fixture must contain two candidates") + } + selection := parsed.Candidates[1].SelectionID + request := map[string]string{"proxy_url": input, "proxy_protocol": "https", "proxy_selection_id": selection} + raw, _ := json.Marshal(request) + normal, err := m.ValidateConfig(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + var saved Config + if json.Unmarshal(normal, &saved) != nil { + t.Fatal("invalid saved config") + } + if saved.ProxyURL != "socks5://u:two@[::1]:1080" || saved.ProxyProtocol != "" || saved.ProxySelectionID != "" || strings.Contains(string(normal), "first.example") { + t.Fatal("save retained a draft, changed explicit protocol, or persisted a selection") + } + var endpoint string + m.prepareProxy = func(_ context.Context, _ HostCaller, normal string, explicit bool) (*http.Client, *ProxyResult, error) { + if !explicit { + t.Error("proxy test must retain explicit certificate policy") + } + endpoint = normal + return nil, &ProxyResult{Code: "fixture", Protocol: "socks5"}, nil + } + invoke := func(id string) error { + payload, _ := json.Marshal(map[string]string{"proxy_url": input, "protocol": "https", "proxy_selection_id": id}) + _, err := m.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityAdmin, Operation: "proxy.test", Payload: payload}) + return err + } + if err := invoke(selection); err != nil { + t.Fatal(err) + } + if endpoint != saved.ProxyURL { + t.Fatal("save and test selected different endpoints") + } + for _, id := range []string{"", "outdated"} { + request["proxy_selection_id"] = id + raw, _ = json.Marshal(request) + _, saveErr := m.ValidateConfig(context.Background(), raw) + testErr := invoke(id) + var saveSelection, testSelection *proxytransport.SelectionError + if !errors.As(saveErr, &saveSelection) || !errors.As(testErr, &testSelection) || saveSelection.Code != testSelection.Code { + t.Fatal("save and test must reject with the same selection error") + } + } +} diff --git a/backend/internal/handler/admin/account_batch_connection.go b/backend/internal/handler/admin/account_batch_connection.go new file mode 100644 index 000000000000..287499209aab --- /dev/null +++ b/backend/internal/handler/admin/account_batch_connection.go @@ -0,0 +1,208 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "slices" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type batchConnectionSnapshot struct { + ModelID string `json:"model_id"` + MappedModelID string `json:"mapped_model_id"` + ReasoningEffort string `json:"reasoning_effort"` + WirePlatform string `json:"wire_platform"` + APIProtocol string `json:"api_protocol"` + PlanStamp string `json:"plan_stamp"` +} + +func connectionPlanStamp(plan *accountTestPlanView, account *service.Account, modelID, effectiveModel string) string { + var selected map[string]any + for _, model := range plan.Models { + if model["id"] == modelID { + selected = model + break + } + } + // Only selected-model execution facts participate. Display names, catalog + // order and unrelated models must not invalidate an accepted plan. + facts := map[string]any{"purpose": "connection", "wire_platform": plan.WirePlatform, + "mapped_model_id": effectiveModel, "api_protocol": account.GetAPIProtocol(), + "policy_stamp": plan.PolicyStamp} + for _, key := range []string{"endpoints", "input_modalities", "output_modalities", "reasoning_efforts", "default_reasoning_effort", "live_upstream_id"} { + facts[key] = selected[key] + } + raw, _ := json.Marshal(facts) + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +func (h *AccountHandler) executeBatchConnectionTest(ctx context.Context, raw json.RawMessage, item service.AccountJobItem) service.AccountJobExecutionResult { + startedAt := time.Now() + id, ok := accountJobTarget(item) + if !ok { + return accountJobFailed(item.ID, "target_missing") + } + testCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + metadata := map[string]any{"account_id": id, "connection_status": "failed", "recovery_status": "not_attempted", "stage": "selection"} + if len(item.Metadata) > 0 { + _ = json.Unmarshal(item.Metadata, &metadata) + } + metadata["stage"], metadata["connection_status"], metadata["recovery_status"] = "selection", "failed", "not_attempted" + metadata["message"] = "" + fail := func(code string, causes ...error) service.AccountJobExecutionResult { + metadata["latency_ms"] = time.Since(startedAt).Milliseconds() + metadata["connection_status"] = "failed" + if ctx.Err() != nil { + metadata["message"] = "" + metadata["connection_status"] = "canceled" + encoded, _ := json.Marshal(metadata) + return service.AccountJobExecutionResult{ItemID: item.ID, Status: service.AccountJobItemStatusCanceled, Metadata: encoded} + } + if errors.Is(testCtx.Err(), context.DeadlineExceeded) { + code = "test_timeout" + } + result := accountJobFailed(item.ID, code) + metadata["message"] = result.ErrorMessage + if len(causes) > 0 && service.AccountTestFailureCode(causes[0]) == code { + metadata["message"] = service.AccountTestSafeFailureMessage(causes[0]) + } + result.Metadata, _ = json.Marshal(metadata) + return result + } + var request batchTestJobPayload + if json.Unmarshal(raw, &request) != nil || service.ValidateAccountTestPrompt(request.Prompt) != nil { + return fail("payload_invalid") + } + models, prepared := ctx.Value(batchTestModelContextKey{}).(map[int64]string) + if !prepared { + var err error + _, models, err = request.normalizeContext(testCtx) + if err != nil { + return fail("payload_invalid") + } + } + model, exists := models[id] + if !exists { + return fail("target_missing") + } + effort := "" + for _, selected := range request.Items { + if selected.AccountID == id { + effort = selected.ReasoningEffort + break + } + } + if h.accountTestService == nil || h.adminService == nil { + return fail("test_unavailable") + } + account, err := h.adminService.GetAccount(testCtx, id) + if err != nil || account == nil { + return fail("account_not_found") + } + metadata["name"] = account.Name + plan, err := h.accountTestPlan(testCtx, account) + if err != nil { + return fail("test_catalog_failed") + } + view := plan.ModeViews["connection"] + var previous struct { + Plan *batchConnectionSnapshot `json:"execution_plan"` + } + if json.Unmarshal(item.Metadata, &previous) != nil && len(item.Metadata) > 0 { + return fail("payload_invalid") + } + if previous.Plan != nil { + model, effort = previous.Plan.ModelID, previous.Plan.ReasoningEffort + if !slices.Contains(view.ModelIDs, model) { + return fail("test_plan_changed") + } + } else if model == "" { + model = view.DefaultModelID + } + if model == "" { + return fail("test_no_text_model") + } + if !slices.Contains(view.ModelIDs, model) { + return fail("test_model_unsupported") + } + if previous.Plan == nil && effort == "" { + levels, defaultEffort := service.AccountTestReasoningOptions(account, model) + if defaultEffort != "" && slices.Contains(levels, defaultEffort) { + effort = defaultEffort + } + } + if err := service.ValidateAccountTestReasoningContext(testCtx, account, model, service.AccountTestModeDefault, effort); err != nil { + if previous.Plan != nil { + return fail("test_plan_changed") + } + return fail("test_reasoning_unsupported") + } + actualModel, mappingErr := service.ResolveAccountTestExecutionModel(testCtx, account, model) + if mappingErr != nil { + return fail("test_catalog_failed") + } + stamp := connectionPlanStamp(plan, account, model, actualModel) + if actualModel == "" || (previous.Plan != nil && (previous.Plan.MappedModelID != actualModel || previous.Plan.PlanStamp != stamp)) { + return fail("test_plan_changed") + } + snapshot := batchConnectionSnapshot{ModelID: model, MappedModelID: actualModel, ReasoningEffort: effort, + WirePlatform: plan.WirePlatform, APIProtocol: account.GetAPIProtocol(), PlanStamp: stamp} + metadata["execution_plan"], metadata["model_id"], metadata["reasoning_effort"] = snapshot, snapshot.MappedModelID, effort + metadata["requested_model_id"], metadata["stage"] = model, "connection" + metadata["connection_status"] = "running" + encoded, _ := json.Marshal(metadata) + if err := h.accountJobs.SaveExecutionSnapshot(testCtx, item.JobID, item.ID, encoded); err != nil { + return fail("test_plan_persist_failed") + } + result, testErr := h.accountTestService.RunBatchTestBackgroundWithOptions(testCtx, id, model, request.Prompt, + service.AccountTestOptions{ReasoningEffort: effort, ExpectedMappedModel: snapshot.MappedModelID, + ExpectedWirePlatform: snapshot.WirePlatform, ExpectedAPIProtocol: snapshot.APIProtocol}) + if result != nil { + metadata["latency_ms"], metadata["output_limited"] = result.LatencyMs, result.OutputLimited + if result.ActualModel != "" { + metadata["model_id"] = result.ActualModel + } + if result.EffectiveReasoningEffort != "" { + metadata["effective_reasoning_effort"] = result.EffectiveReasoningEffort + } + } + if testErr != nil || result == nil || result.Status != "success" { + if ctx.Err() != nil { + canceled := fail("test_failed") + canceled.Status = service.AccountJobItemStatusCanceled + return canceled + } + code := service.AccountTestFailureCode(testErr) + if testCtx.Err() == context.DeadlineExceeded { + code = "test_timeout" + } + return fail(code, testErr) + } + // A completed model call stays successful even when recovery fails, or Stop + // arrives after the terminal event. Failed-item retry must not resend it. + metadata["connection_status"], metadata["stage"] = "succeeded", "complete" + recoveryCtx, release := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer release() + metadata["recovery_status"] = "not_needed" + if h.rateLimitService == nil { + metadata["recovery_status"] = "warning" + } else { + recovered, recoverErr := h.rateLimitService.RecoverAccountAfterSuccessfulTest(recoveryCtx, id) + switch { + case recoverErr != nil: + metadata["recovery_status"] = "warning" + case recovered != nil && recovered.ManualStatePreserved: + metadata["recovery_status"] = "manual_state_preserved" + case recovered != nil && (recovered.ClearedError || recovered.ClearedRateLimit): + metadata["recovery_status"] = "recovered" + } + } + return accountJobSucceeded(item.ID, metadata) +} diff --git a/backend/internal/handler/admin/account_batch_connection_test.go b/backend/internal/handler/admin/account_batch_connection_test.go new file mode 100644 index 000000000000..4c5bf5b67f85 --- /dev/null +++ b/backend/internal/handler/admin/account_batch_connection_test.go @@ -0,0 +1,152 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +type batchConnectionAdmin struct { + service.AdminService + accounts map[int64]*service.Account +} + +func (a *batchConnectionAdmin) GetAccount(_ context.Context, id int64) (*service.Account, error) { + account := a.accounts[id] + if account == nil { + return nil, service.ErrAccountNotFound + } + return account, nil +} + +type batchConnectionRepo struct { + service.AccountRepository + accounts map[int64]*service.Account + saved map[int64]json.RawMessage + called []int64 + recovery *service.SuccessfulTestRecoveryResult + recoveryErr error + t *testing.T +} + +func (r *batchConnectionRepo) GetByID(_ context.Context, id int64) (*service.Account, error) { + require.NotEmpty(r.t, r.saved[id], "execution plan must be durable before model IO begins") + r.called = append(r.called, id) + return r.accounts[id], nil +} +func (r *batchConnectionRepo) RecoverAfterSuccessfulTest(context.Context, int64) (*service.SuccessfulTestRecoveryResult, error) { + return r.recovery, r.recoveryErr +} + +type batchConnectionJobRepo struct { + service.AccountJobRepository + saved map[int64]json.RawMessage + err error +} + +func (r *batchConnectionJobRepo) SaveExecutionSnapshot(_ context.Context, _ int64, itemID int64, raw json.RawMessage) error { + if r.err != nil { + return r.err + } + r.saved[itemID] = append(json.RawMessage(nil), raw...) + return nil +} + +func TestBatchConnectionWorkerPlanSnapshot(t *testing.T) { + account := &service.Account{ID: 1, Name: "good", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{"gpt-image-2": "gpt-image-2", "text-model": "text-model"}}, Extra: map[string]any{"synthetic_ui_test": true}} + bad := &service.Account{ID: 2, Name: "catalog unavailable", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"base_url": "ftp://invalid"}} + saved := map[int64]json.RawMessage{} + accounts := map[int64]*service.Account{1: account, 2: bad} + repo := &batchConnectionRepo{accounts: accounts, saved: saved, t: t, recovery: &service.SuccessfulTestRecoveryResult{ClearedError: true}} + jobRepo := &batchConnectionJobRepo{saved: saved} + tests := service.NewAccountTestService(repo, nil, nil, nil, nil, nil, &config.Config{}, nil) + h := &AccountHandler{adminService: &batchConnectionAdmin{accounts: accounts}, accountTestService: tests, + accountJobs: service.NewAccountJobService(jobRepo, accountJobTestEncryptor{}), rateLimitService: service.NewRateLimitService(repo, nil, &config.Config{}, nil, nil)} + raw := json.RawMessage(`{"items":[{"account_id":1,"selection_mode":"auto"},{"account_id":2,"selection_mode":"auto"}]}`) + run := func(id int64, prior json.RawMessage) service.AccountJobExecutionResult { + return h.executeBatchConnectionTest(context.Background(), raw, service.AccountJobItem{ID: id, JobID: 88, TargetAccountID: &id, Metadata: prior}) + } + failed := run(2, nil) + require.Equal(t, "test_catalog_failed", failed.ErrorCode) + result := run(1, nil) + require.Equal(t, service.AccountJobItemStatusSucceeded, result.Status) + var meta map[string]any + require.NoError(t, json.Unmarshal(result.Metadata, &meta)) + require.Equal(t, "text-model", meta["model_id"]) + require.Equal(t, "recovered", meta["recovery_status"]) + require.NotContains(t, string(result.Metadata), "healthy and interactive") + frozen := append(json.RawMessage(nil), saved[1]...) + account.Credentials["model_mapping"] = map[string]any{"aaa-new-default": "aaa-new-default", "text-model": "text-model"} + repo.recoveryErr = errors.New("private recovery error") + result = run(1, frozen) + require.Equal(t, service.AccountJobItemStatusSucceeded, result.Status) + require.NoError(t, json.Unmarshal(result.Metadata, &meta)) + require.Equal(t, "text-model", meta["model_id"], "retry must not follow a changed default") + require.Equal(t, "warning", meta["recovery_status"]) + require.NotContains(t, string(result.Metadata), "private recovery error") + repo.recoveryErr = nil + repo.recovery = &service.SuccessfulTestRecoveryResult{ManualStatePreserved: true} + result = run(1, frozen) + require.NoError(t, json.Unmarshal(result.Metadata, &meta)) + require.Equal(t, "manual_state_preserved", meta["recovery_status"]) + before := len(repo.called) + account.Credentials["model_mapping"] = map[string]any{"text-model": "changed-wire"} + result = run(1, frozen) + require.Equal(t, "test_plan_changed", result.ErrorCode) + require.Len(t, repo.called, before, "a changed plan must not call the model") + account.Credentials["model_mapping"] = map[string]any{"text-model": "text-model"} + jobRepo.err = errors.New("database unavailable") + result = run(1, nil) + require.Equal(t, "test_plan_persist_failed", result.ErrorCode) + require.Len(t, repo.called, before, "an unpersisted selection must not call the model") +} + +func TestAccountTestConnectionPurpose(t *testing.T) { + account := &service.Account{ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey} + plan, err := ordinaryAccountTestPlan(account, []map[string]any{ + {"id": "gemini-3.1-flash-image"}, {"id": "gemini-2.5-flash"}, + {"id": "image-caption-conversation", "output_modalities": []string{"text"}}, + {"id": "audio-only", "output_modalities": []string{"audio"}}, + }) + require.NoError(t, err) + require.Equal(t, "gemini-2.5-flash", plan.ModeViews["connection"].DefaultModelID) + require.Equal(t, []string{"gemini-2.5-flash", "image-caption-conversation"}, plan.ModeViews["connection"].ModelIDs) + require.True(t, accountConnectionModel(account, map[string]any{"id": "gemini-text", "endpoints": []any{"generateContent"}})) +} + +func TestBatchConnectionSelectionCancel(t *testing.T) { + upstream := &blockingBatchCatalogUpstream{entered: make(chan struct{}, 1), release: make(chan struct{})} + cfg := &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}} + gateway := service.NewOpenAIGatewayService(nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil) + tests := service.NewAccountTestService(nil, nil, nil, nil, nil, upstream, cfg, nil) + tests.SetOpenAIGatewayService(gateway) + a := &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture", "base_url": "https://catalog.example"}} + h := &AccountHandler{adminService: &batchConnectionAdmin{accounts: map[int64]*service.Account{1: a}}, accountTestService: tests} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan service.AccountJobExecutionResult, 1) + go func() { + done <- h.executeBatchConnectionTest(ctx, json.RawMessage(`{"items":[{"account_id":1,"selection_mode":"auto"}]}`), service.AccountJobItem{ID: 1, JobID: 2, TargetAccountID: &a.ID}) + }() + select { + case <-upstream.entered: + case <-time.After(time.Second): + t.Fatal("catalog did not start") + } + cancel() + select { + case result := <-done: + require.Equal(t, service.AccountJobItemStatusCanceled, result.Status) + require.Empty(t, result.ErrorCode) + require.NotContains(t, string(result.Metadata), "execution_plan") + case <-time.After(time.Second): + t.Fatal("selection did not stop") + } +} diff --git a/backend/internal/handler/admin/account_job_execution.go b/backend/internal/handler/admin/account_job_execution.go index ef18a4cc1783..3129fecab3fb 100644 --- a/backend/internal/handler/admin/account_job_execution.go +++ b/backend/internal/handler/admin/account_job_execution.go @@ -6,9 +6,7 @@ import ( "errors" "strconv" "strings" - "time" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -41,72 +39,7 @@ func (h *AccountHandler) ExecuteAccountJob( func (h *AccountHandler) executeAccountJobItem(ctx context.Context, kind string, raw json.RawMessage, item service.AccountJobItem) service.AccountJobExecutionResult { switch kind { case service.AccountJobKindBatchTest: - var request batchTestJobPayload - if json.Unmarshal(raw, &request) != nil || service.ValidateAccountTestPrompt(request.Prompt) != nil { - return accountJobFailed(item.ID, "payload_invalid") - } - id, ok := accountJobTarget(item) - if !ok { - return accountJobFailed(item.ID, "target_missing") - } - models, prepared := ctx.Value(batchTestModelContextKey{}).(map[int64]string) - if !prepared { - var req batchTestJobPayload - if json.Unmarshal(raw, &req) != nil { - return accountJobFailed(item.ID, "payload_invalid") - } - var err error - _, models, err = req.normalize() - if err != nil { - return accountJobFailed(item.ID, "payload_invalid") - } - } - model, exists := models[id] - if !exists { - return accountJobFailed(item.ID, "target_missing") - } - if h.accountTestService == nil { - return accountJobFailed(item.ID, "test_unavailable") - } - testCtx, cancel := context.WithTimeout(ctx, 90*time.Second) - defer cancel() - if _, err := service.PlanBatchAccountTests(testCtx, extensionv1.BatchTestPlanningRequest{HasLegacy: true, AccountIDs: []int64{id}, ModelID: model}); err != nil { - return accountJobFailed(item.ID, "test_unavailable") - } - effort := "" - for _, selected := range request.Items { - if selected.AccountID == id { - effort = selected.ReasoningEffort - break - } - } - result, err := h.accountTestService.RunBatchTestBackgroundWithOptions(testCtx, id, model, request.Prompt, service.AccountTestOptions{ReasoningEffort: effort}) - if ctx.Err() != nil { - return service.AccountJobExecutionResult{ItemID: item.ID, Status: service.AccountJobItemStatusCanceled} - } - metadata := map[string]any{"account_id": id, "model_id": model} - if effort != "" { - metadata["reasoning_effort"] = effort - } - if result != nil { - metadata["latency_ms"] = result.LatencyMs - if result.EffectiveReasoningEffort != "" { - metadata["effective_reasoning_effort"] = result.EffectiveReasoningEffort - } - } - if err != nil || result == nil || result.Status != "success" { - code := "test_failed" - if errors.Is(err, service.ErrAccountTestModelUnsupported) { - code = "test_model_unsupported" - } - if testCtx.Err() == context.DeadlineExceeded { - code = "test_timeout" - } - failure := accountJobFailed(item.ID, code) - failure.Metadata, _ = json.Marshal(metadata) - return failure - } - return accountJobSucceeded(item.ID, metadata) + return h.executeBatchConnectionTest(ctx, raw, item) case service.AccountJobKindBatchDelete: id, ok := accountJobTarget(item) if !ok { diff --git a/backend/internal/handler/admin/account_job_handler.go b/backend/internal/handler/admin/account_job_handler.go index 1c5304e84586..5a1e1cb99b73 100644 --- a/backend/internal/handler/admin/account_job_handler.go +++ b/backend/internal/handler/admin/account_job_handler.go @@ -27,6 +27,9 @@ func (h *AccountJobHandler) List(c *gin.Context) { response.ErrorFrom(c, accountJobHTTPError(err)) return } + for i := range jobs.Items { + restrictAccountJobRetryActor(c, &jobs.Items[i]) + } response.Success(c, jobs) } @@ -40,6 +43,7 @@ func (h *AccountJobHandler) Get(c *gin.Context) { response.ErrorFrom(c, accountJobHTTPError(err)) return } + restrictAccountJobRetryActor(c, job) response.Success(c, job) } @@ -117,6 +121,17 @@ func accountJobActorID(c *gin.Context) (int64, bool) { return subject.UserID, true } +func restrictAccountJobRetryActor(c *gin.Context, job *service.AccountJob) { + if job == nil || !job.RetryEligible { + return + } + actor, ok := servermiddleware.GetAuthSubjectFromContext(c) + if !ok || actor.UserID != job.CreatedBy { + job.RetryEligible = false + job.RetryUnavailableReason = "different_administrator" + } +} + func accountJobPathID(c *gin.Context) (int64, bool) { jobID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || jobID <= 0 { diff --git a/backend/internal/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index 6e55198b9275..beac16f97254 100644 --- a/backend/internal/handler/admin/account_test_plan.go +++ b/backend/internal/handler/admin/account_test_plan.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "slices" "sort" "strings" @@ -101,12 +102,70 @@ func basicAccountTestPlan(account *service.Account, models []map[string]any, def ids = append(ids, id) } view := accountTestModeView{ModelIDs: ids, DefaultModelID: defaultID} + connection := accountTestModeView{ModelIDs: []string{}} + for _, model := range models { + id, _ := model["id"].(string) + if accountConnectionModel(account, model) { + connection.ModelIDs = append(connection.ModelIDs, id) + } + } + if slices.Contains(connection.ModelIDs, defaultID) { + connection.DefaultModelID = defaultID + } else if len(connection.ModelIDs) > 0 { + connection.DefaultModelID = connection.ModelIDs[0] + } return &accountTestPlanView{SchemaVersion: 1, AccountID: account.ID, WirePlatform: account.EffectiveWirePlatform(), DefaultMode: "default", Models: models, ModeViews: map[string]accountTestModeView{ - "default": view, "compact": view, "text": view, + "default": view, "compact": view, "text": view, "connection": connection, }} } +// A connection probe needs text conversation. Explicit capability declarations +// take precedence; catalog entries without them use the same model identities +// and account mappings used by the test dispatcher. +func accountConnectionModel(account *service.Account, model map[string]any) bool { + id, _ := model["id"].(string) + if kind, _ := model["kind"].(string); kind != "" { + return kind == "text" + } + declaredText := false + for _, key := range []string{"output_modalities", "input_modalities"} { + if values, ok := model[key].([]any); ok && len(values) > 0 { + text := false + for _, value := range values { + text = text || value == "text" + } + if !text { + return false + } + if key == "output_modalities" { + declaredText = true + } + } + } + if declaredText { + return true + } + var catalogTargets []string + if target, ok := model["live_upstream_id"].(string); ok { + catalogTargets = []string{target} + } + if !service.AccountTestSupportsTextConversation(account, id, catalogTargets...) { + return false + } + if endpoints, ok := model["endpoints"].([]any); ok && len(endpoints) > 0 { + for _, value := range endpoints { + endpoint, _ := value.(string) + lower := strings.ToLower(endpoint) + if strings.Contains(lower, "responses") || strings.Contains(lower, "chat") || strings.Contains(lower, "messages") || strings.Contains(lower, "generatecontent") { + return true + } + } + return false + } + return true +} + // These choices originate in the official single-account test UI. Keep their // current behavior in one core view producer, not in two frontend bundles. func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPlanView, error) { @@ -145,7 +204,7 @@ func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPla return plan, nil } plan.DefaultMode = "text" - plan.ModeViews = map[string]accountTestModeView{} + plan.ModeViews = map[string]accountTestModeView{"connection": plan.ModeViews["connection"]} for _, mode := range []string{"text", "image", "video", "search", "tts", "stt", "realtime"} { view := accountTestModeView{ModelIDs: []string{}} for _, model := range models { @@ -177,5 +236,10 @@ func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPla plan.ModeViews[mode] = view } plan.ModeViews["default"] = plan.ModeViews["text"] + if slices.Contains(plan.ModeViews["connection"].ModelIDs, plan.ModeViews["text"].DefaultModelID) { + connection := plan.ModeViews["connection"] + connection.DefaultModelID = plan.ModeViews["text"].DefaultModelID + plan.ModeViews["connection"] = connection + } return plan, nil } diff --git a/backend/internal/handler/admin/setting_codex_proxy_selection_test.go b/backend/internal/handler/admin/setting_codex_proxy_selection_test.go new file mode 100644 index 000000000000..327bc21646b6 --- /dev/null +++ b/backend/internal/handler/admin/setting_codex_proxy_selection_test.go @@ -0,0 +1,59 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/proxytransport" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCodexProxyParseIsPureAndSelectionErrorsAreShared(t *testing.T) { + h := &SettingHandler{} // No gateway, database, DNS or transport available. + call := func(handler gin.HandlerFunc, body map[string]string) *httptest.ResponseRecorder { + raw, err := json.Marshal(body) + require.NoError(t, err) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(raw)) + ctx.Request.Header.Set("Content-Type", "application/json") + handler(ctx) + return rec + } + input := "http://fixture-user:fixture-password@first.example:80\nhttp://fixture-user:other-password@second.example:80" + parsed := call(h.ParseCodexTicketProxy, map[string]string{"proxy_url": input, "protocol": "https"}) + require.Equal(t, http.StatusOK, parsed.Code) + require.Equal(t, "no-store", parsed.Header().Get("Cache-Control")) + for _, value := range []string{"fixture-user", "fixture-password", "other-password", "http://"} { + require.NotContains(t, parsed.Body.String(), value) + } + var envelope struct { + Data proxytransport.ParseResult `json:"data"` + } + require.NoError(t, json.Unmarshal(parsed.Body.Bytes(), &envelope)) + require.Len(t, envelope.Data.Candidates, 2) + for _, selection := range []string{"", "old-selection"} { + body := map[string]string{"proxy_url": input, "protocol": "https", "proxy_selection_id": selection} + tested := call(h.TestCodexTicketProxy, body) + delete(body, "protocol") + body["proxy_protocol"] = "https" + saved := call(h.UpdateNativeCodexConfiguration, body) + require.Equal(t, http.StatusBadRequest, tested.Code) + require.Equal(t, tested.Code, saved.Code) + var testError, saveError struct { + Reason string `json:"reason"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(tested.Body.Bytes(), &testError)) + require.NoError(t, json.Unmarshal(saved.Body.Bytes(), &saveError)) + require.Equal(t, testError, saveError) + require.False(t, strings.Contains(saved.Body.String(), "fixture-password")) + } + accepted := call(h.TestCodexTicketProxy, map[string]string{"proxy_url": input, "protocol": "https", "proxy_selection_id": envelope.Data.Candidates[0].SelectionID}) + require.Equal(t, http.StatusServiceUnavailable, accepted.Code, "a valid selection reaches the unavailable gateway without connecting") +} diff --git a/backend/internal/handler/admin/setting_codex_runtime.go b/backend/internal/handler/admin/setting_codex_runtime.go index 15751b10ca08..5aeb40b97f3f 100644 --- a/backend/internal/handler/admin/setting_codex_runtime.go +++ b/backend/internal/handler/admin/setting_codex_runtime.go @@ -51,11 +51,19 @@ func (h *SettingHandler) UpdateNativeCodexConfiguration(c *gin.Context) { response.BadRequest(c, "Invalid Codex routing configuration") return } - if _, err := service.NormalizeNativeCodexConfig(c.Request.Context(), raw); err != nil { + normalized, err := service.NormalizeNativeCodexConfig(c.Request.Context(), raw) + if err != nil { + if writeCodexProxySelectionError(c, err) { + return + } response.BadRequest(c, "Invalid Codex routing configuration") return } - if err := h.codexTicketGateway.UpdateNativeCodexConfiguration(c.Request.Context(), raw, h.nativeCodexConfigEncryptor); err != nil { + if h.codexTicketGateway == nil { + response.Error(c, 503, "Codex runtime is unavailable") + return + } + if err := h.codexTicketGateway.UpdateNativeCodexConfiguration(c.Request.Context(), normalized, h.nativeCodexConfigEncryptor); err != nil { if errors.Is(err, service.ErrNativeCodexRuntimeChanged) { response.Error(c, 409, "Codex runtime configuration changed; reload and retry") } else { diff --git a/backend/internal/handler/admin/setting_codex_ticket_proxy.go b/backend/internal/handler/admin/setting_codex_ticket_proxy.go index 44c17775a8ef..4e6ab83910ab 100644 --- a/backend/internal/handler/admin/setting_codex_ticket_proxy.go +++ b/backend/internal/handler/admin/setting_codex_ticket_proxy.go @@ -1,30 +1,65 @@ package admin import ( + "errors" + "net/http" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/proxytransport" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) +type codexTicketProxyInput struct { + ProxyURL string `json:"proxy_url"` + Protocol string `json:"protocol"` + ProxySelectionID string `json:"proxy_selection_id"` +} + +// ParseCodexTicketProxy only recognizes syntax. It has no gateway, storage, +// certificate, DNS or network dependency and never persists a proxy draft. +func (h *SettingHandler) ParseCodexTicketProxy(c *gin.Context) { + c.Header("Cache-Control", "no-store") + var req codexTicketProxyInput + if c.ShouldBindJSON(&req) != nil { + response.BadRequest(c, "请求格式不正确") + return + } + response.Success(c, proxytransport.Parse(req.ProxyURL, req.Protocol)) +} + +func writeCodexProxySelectionError(c *gin.Context, err error) bool { + var invalid *proxytransport.SelectionError + if !errors.As(err, &invalid) { + return false + } + c.JSON(http.StatusBadRequest, response.Response{Code: http.StatusBadRequest, Reason: invalid.Code, Message: invalid.Error(), Data: invalid.Result}) + return true +} + func (h *SettingHandler) SetCodexTicketGateway(gateway *service.OpenAIGatewayService) { h.codexTicketGateway = gateway } func (h *SettingHandler) TestCodexTicketProxy(c *gin.Context) { c.Header("Cache-Control", "no-store") - var req struct { - ProxyURL string `json:"proxy_url"` - Protocol string `json:"protocol"` - } + var req codexTicketProxyInput if c.ShouldBindJSON(&req) != nil { response.BadRequest(c, "请求格式不正确") return } + normal, err := proxytransport.Resolve(req.ProxyURL, req.Protocol, req.ProxySelectionID) + if err == nil && normal == "" { + err = &proxytransport.SelectionError{Code: "invalid_proxy", Result: proxytransport.Parse(req.ProxyURL, req.Protocol)} + } + if writeCodexProxySelectionError(c, err) { + return + } if h.codexTicketGateway == nil { response.Error(c, 503, "票据服务不可用") return } - result, err := h.codexTicketGateway.TestCodexTicketProxyWithProtocol(c.Request.Context(), req.ProxyURL, req.Protocol) + result, err := h.codexTicketGateway.TestCodexTicketProxyWithProtocol(c.Request.Context(), normal, "") if err != nil { response.InternalError(c, "无法完成代理测试") return diff --git a/backend/internal/handler/admin/system_prompt_handler.go b/backend/internal/handler/admin/system_prompt_handler.go index 42ad5cac1a5f..2681125903aa 100644 --- a/backend/internal/handler/admin/system_prompt_handler.go +++ b/backend/internal/handler/admin/system_prompt_handler.go @@ -1,14 +1,8 @@ package admin import ( - "encoding/json" "errors" - "io" "net/http" - "strconv" - "strings" - "time" - "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -16,140 +10,12 @@ import ( "github.com/gin-gonic/gin" ) -// SystemPromptHandler exposes the independent business System Prompt catalog -// and singleton runtime policy to ordinary authenticated administrators. type SystemPromptHandler struct { - service *service.BusinessSystemPromptService - skillRegistry *service.RemoteSkillRegistryService + service *service.BusinessSystemPromptService } -func NewSystemPromptHandler(promptService *service.BusinessSystemPromptService, skillRegistry *service.RemoteSkillRegistryService) *SystemPromptHandler { - return &SystemPromptHandler{service: promptService, skillRegistry: skillRegistry} -} - -type systemPromptRuntimeResponse struct { - Enabled bool `json:"enabled"` - ExposeServerPrompt bool `json:"expose_server_prompt"` - CompactEnabled bool `json:"compact_enabled"` - TemplateID int64 `json:"template_id"` - VersionID int64 `json:"version_id"` - TemplateVersion int64 `json:"template_version"` - Revision int64 `json:"revision"` - SHA256 string `json:"sha256"` - ByteLength int `json:"byte_length"` - CompositionMode string `json:"composition_mode"` - BundleID string `json:"bundle_id,omitempty"` - BundleManifestSHA256 string `json:"bundle_manifest_sha256,omitempty"` - RegistryRevision int64 `json:"registry_revision,omitempty"` - RegistryRawTreeSHA256 string `json:"registry_raw_tree_sha256,omitempty"` - RegistryEffectiveTreeSHA256 string `json:"registry_effective_tree_sha256,omitempty"` - RegistryPromptRawSHA256 string `json:"registry_prompt_raw_sha256,omitempty"` - RegistryPromptEffectiveSHA256 string `json:"registry_prompt_effective_sha256,omitempty"` - RegistryUpstreamSourceID string `json:"registry_upstream_source_id,omitempty"` - RegistryUpstreamRoot string `json:"registry_upstream_root,omitempty"` - RegistryPublicRoot string `json:"registry_public_root,omitempty"` - BundleAvailable bool `json:"bundle_available"` - BundleDegraded bool `json:"bundle_degraded"` - DegradedReason string `json:"degraded_reason,omitempty"` - Degraded bool `json:"degraded"` - UpdatedAt time.Time `json:"updated_at"` -} - -type systemPromptEnvelope struct { - Templates []service.BusinessSystemPromptTemplate `json:"templates"` - Runtime systemPromptRuntimeResponse `json:"runtime"` -} - -type systemPromptCreateRequest struct { - Slug string `json:"slug" binding:"required"` - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Body string `json:"body" binding:"required"` - Note string `json:"note"` - CompositionMode string `json:"composition_mode"` - BundleID string `json:"bundle_id"` - BundleManifestSHA256 string `json:"bundle_manifest_sha256"` - ExpectedRevision int64 `json:"expected_revision"` -} - -type systemPromptUpdateRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - ExpectedRevision int64 `json:"expected_revision"` -} - -type systemPromptVersionRequest struct { - Body string `json:"body" binding:"required"` - Note string `json:"note"` - CompositionMode string `json:"composition_mode"` - BundleID string `json:"bundle_id"` - BundleManifestSHA256 string `json:"bundle_manifest_sha256"` - ExpectedLatestVersion int64 `json:"expected_latest_version"` - ExpectedRevision int64 `json:"expected_revision"` -} - -type systemPromptSourceSyncRequest struct { - ExpectedLatestVersion int64 `json:"expected_latest_version" binding:"required"` - ExpectedRevision int64 `json:"expected_revision" binding:"required"` -} - -type systemPromptSourceSyncVersionResponse struct { - ID int64 `json:"id"` - TemplateID int64 `json:"template_id"` - Version int64 `json:"version"` - SHA256 string `json:"sha256"` - ByteLength int `json:"byte_length"` - SourceRepository string `json:"source_repository,omitempty"` - SourceCommit string `json:"source_commit,omitempty"` - SourceVersion string `json:"source_version,omitempty"` - SourceArtifact string `json:"source_artifact,omitempty"` - SourceArtifactSHA256 string `json:"source_artifact_sha256,omitempty"` - SourceLicenseSHA256 string `json:"source_license_sha256,omitempty"` -} - -type systemPromptSourceSyncResponse struct { - Status service.BusinessSystemPromptSourceSyncStatus `json:"status"` - Version *systemPromptSourceSyncVersionResponse `json:"version,omitempty"` -} - -type remoteSkillPromptVersionDetailResponse struct { - service.RemoteSkillPromptVersion - RawBody string `json:"raw_body"` - EffectiveBody string `json:"effective_body"` -} - -type remoteSkillBundleVersionDetailResponse struct { - service.RemoteSkillBundleVersion - Prompt remoteSkillPromptVersionDetailResponse `json:"prompt"` - FileChanges []service.RemoteSkillFileChange `json:"file_changes"` - Verified bool `json:"verified"` -} - -type systemPromptRuntimeRequest struct { - ExpectedRevision int64 `json:"expected_revision" binding:"required"` - Enabled bool `json:"enabled"` - ExposeServerPrompt bool `json:"expose_server_prompt"` - CompactEnabled bool `json:"compact_enabled"` -} - -type systemPromptDuplicateRequest struct { - Slug string `json:"slug" binding:"required"` - Name string `json:"name" binding:"required"` - ExpectedRevision int64 `json:"expected_revision"` -} - -type systemPromptPreviewRequest struct { - TemplateID int64 `json:"template_id"` - VersionID int64 `json:"version_id"` - ClientInstructions string `json:"client_instructions"` - ServerInstructions string `json:"server_instructions"` - Protocol string `json:"protocol"` - Compact bool `json:"compact"` - Body json.RawMessage `json:"body"` - CompositionMode string `json:"composition_mode"` - BundleID string `json:"bundle_id"` - BundleManifestSHA256 string `json:"bundle_manifest_sha256"` - ClientMode string `json:"client_mode"` +func NewSystemPromptHandler(prompts *service.BusinessSystemPromptService) *SystemPromptHandler { + return &SystemPromptHandler{service: prompts} } func (h *SystemPromptHandler) actorID(c *gin.Context) (int64, bool) { @@ -161,47 +27,20 @@ func (h *SystemPromptHandler) actorID(c *gin.Context) (int64, bool) { return subject.UserID, true } -func businessSystemPromptRuntimeResponse(snapshot service.BusinessSystemPromptSnapshot) systemPromptRuntimeResponse { - return systemPromptRuntimeResponse{ - Enabled: snapshot.Enabled, - ExposeServerPrompt: snapshot.ExposeServerPrompt, - CompactEnabled: snapshot.CompactEnabled, - TemplateID: snapshot.TemplateID, - VersionID: snapshot.VersionID, - TemplateVersion: snapshot.TemplateVersion, - Revision: snapshot.Revision, - SHA256: strings.ToLower(snapshot.SHA256), - ByteLength: snapshot.ByteLength, - CompositionMode: snapshot.CompositionMode, - BundleID: snapshot.BundleID, - BundleManifestSHA256: snapshot.BundleManifestSHA256, - RegistryRevision: snapshot.RegistryRevision, - RegistryRawTreeSHA256: snapshot.RegistryRawTreeSHA256, - RegistryEffectiveTreeSHA256: snapshot.RegistryEffectiveTreeSHA256, - RegistryPromptRawSHA256: snapshot.RegistryPromptRawSHA256, - RegistryPromptEffectiveSHA256: snapshot.RegistryPromptEffectiveSHA256, - RegistryUpstreamSourceID: snapshot.RegistryUpstreamSourceID, - RegistryUpstreamRoot: snapshot.RegistryUpstreamRoot, - RegistryPublicRoot: snapshot.RegistryPublicRoot, - BundleAvailable: snapshot.BundleAvailable, - BundleDegraded: snapshot.BundleDegraded, - DegradedReason: snapshot.DegradedReason, - Degraded: snapshot.Degraded, - UpdatedAt: snapshot.UpdatedAt, - } +// Retired is routed only inside the existing authenticated admin group. No +// legacy management service is reachable from these compatibility URLs. +func (h *SystemPromptHandler) Retired(c *gin.Context) { + response.ErrorWithDetails(c, http.StatusGone, "This prompt management endpoint is retired; use the prompt config editor", "system_prompt_management_retired", nil) } -func remoteSkillVersionDetailResponse(detail service.RemoteSkillBundleVersionDetail) remoteSkillBundleVersionDetailResponse { - return remoteSkillBundleVersionDetailResponse{ - RemoteSkillBundleVersion: detail.RemoteSkillBundleVersion, - Prompt: remoteSkillPromptVersionDetailResponse{ - RemoteSkillPromptVersion: detail.Prompt, - RawBody: detail.Prompt.RawBody, - EffectiveBody: detail.Prompt.EffectiveBody, - }, - FileChanges: detail.FileChanges, - Verified: detail.Verified, +func (h *SystemPromptHandler) RuleHistory(c *gin.Context) { + versions, err := h.service.PromptRuleHistory(c.Request.Context(), c.Param("rule_id")) + if err != nil { + writeBusinessSystemPromptError(c, err) + return } + c.Header("Cache-Control", "no-store") + response.Success(c, versions) } func writeBusinessSystemPromptError(c *gin.Context, err error) { @@ -238,708 +77,3 @@ func writeBusinessSystemPromptError(c *gin.Context, err error) { response.ErrorFrom(c, err) } } - -func (h *SystemPromptHandler) SkillRegistry(c *gin.Context) { - if h.skillRegistry == nil { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - versions, err := h.skillRegistry.ListVersions(c.Request.Context()) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - profile, err := service.LoadRemoteSkillRegistryProfile(c.Request.Context()) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, gin.H{ - "runtime": h.skillRegistry.CurrentSnapshot(), "versions": versions, - "source": gin.H{ - "upstream_source_id": profile.SourceID, - "upstream_root": profile.UpstreamRoot, - "public_root": profile.PublicRoot, - }, - }) -} - -func (h *SystemPromptHandler) SkillVersions(c *gin.Context) { - if h.skillRegistry == nil { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - versions, err := h.skillRegistry.ListVersions(c.Request.Context()) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, versions) -} - -func (h *SystemPromptHandler) SkillVersion(c *gin.Context) { - id, ok := parsePositiveID(c, "bundle_version_id") - if !ok { - return - } - version, err := h.skillRegistry.InspectVersion(c.Request.Context(), id) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, remoteSkillVersionDetailResponse(version)) -} - -func (h *SystemPromptHandler) StartSkillSync(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - if h.skillRegistry == nil { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - expectedRevision, promptCapture, err := parseRemoteSkillSyncMultipart(c) - if err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - profile, err := service.LoadRemoteSkillRegistryProfile(c.Request.Context()) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - job, err := h.skillRegistry.StartSync(c.Request.Context(), promptCapture, actorID, expectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "upstream_source_id": profile.SourceID, - "revision": expectedRevision, - "prompt_uploaded": job.PromptCaptureProvided, - "status": job.Status, - "result": "sync_queued", - }) - response.Accepted(c, job) -} - -func parseRemoteSkillSyncMultipart(c *gin.Context) (int64, []byte, error) { - if c.ContentType() != "multipart/form-data" { - return 0, nil, errors.New("multipart/form-data is required") - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(service.BusinessSystemPromptMaxBytes+(1<<20))) - if err := c.Request.ParseMultipartForm(1 << 20); err != nil { - return 0, nil, err - } - if c.Request.MultipartForm == nil { - return 0, nil, errors.New("multipart form is required") - } - defer func() { _ = c.Request.MultipartForm.RemoveAll() }() - revisions := c.Request.MultipartForm.Value["expected_revision"] - if len(revisions) != 1 { - return 0, nil, errors.New("expected_revision must appear exactly once") - } - expectedRevision, err := strconv.ParseInt(strings.TrimSpace(revisions[0]), 10, 64) - if err != nil || expectedRevision < 1 { - return 0, nil, errors.New("expected_revision must be a positive integer") - } - files := c.Request.MultipartForm.File["prompt_capture"] - if len(files) > 1 { - return 0, nil, errors.New("prompt_capture must appear at most once") - } - if len(files) == 0 { - return expectedRevision, nil, nil - } - if files[0].Size <= 0 || files[0].Size > int64(service.BusinessSystemPromptMaxBytes) { - return 0, nil, errors.New("prompt_capture size is invalid") - } - stream, err := files[0].Open() - if err != nil { - return 0, nil, err - } - promptCapture, readErr := io.ReadAll(io.LimitReader(stream, int64(service.BusinessSystemPromptMaxBytes)+1)) - closeErr := stream.Close() - if readErr != nil { - return 0, nil, readErr - } - if closeErr != nil { - return 0, nil, closeErr - } - if len(promptCapture) == 0 || len(promptCapture) > service.BusinessSystemPromptMaxBytes || !utf8.Valid(promptCapture) { - return 0, nil, errors.New("prompt_capture must be non-empty UTF-8 within the size limit") - } - return expectedRevision, promptCapture, nil -} - -func (h *SystemPromptHandler) SkillSync(c *gin.Context) { - id, ok := parsePositiveID(c, "sync_id") - if !ok { - return - } - job, err := h.skillRegistry.GetSyncJob(c.Request.Context(), id) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, job) -} - -func (h *SystemPromptHandler) PublishSkillVersion(c *gin.Context) { - h.publishSkillVersion(c, "publish", "published") -} - -func (h *SystemPromptHandler) RollbackSkillVersion(c *gin.Context) { - h.publishSkillVersion(c, "rollback", "rolled_back") -} - -func (h *SystemPromptHandler) publishSkillVersion(c *gin.Context, action, result string) { - actorID, ok := h.actorID(c) - if !ok { - return - } - versionID, ok := parsePositiveID(c, "bundle_version_id") - if !ok { - return - } - var req struct { - ExpectedRevision int64 `json:"expected_revision" binding:"required"` - ExpectedConfigRevision int64 `json:"expected_config_revision" binding:"required"` - TargetRuleIDs []string `json:"target_rule_ids"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - old := h.skillRegistry.CurrentSnapshot() - if req.TargetRuleIDs == nil { - response.BadRequest(c, "Specify the complete set of rules subscribed to this paired source") - return - } - snapshot, err := h.skillRegistry.PublishVersionForRules(c.Request.Context(), versionID, req.ExpectedRevision, action, actorID, service.PromptSourceTargets{ExpectedRevision: req.ExpectedConfigRevision, RuleIDs: req.TargetRuleIDs}) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - if err := h.service.Reload(c.Request.Context()); err != nil { - writeBusinessSystemPromptError(c, err) - return - } - extra := map[string]any{ - "bundle_version_id": versionID, "revision": snapshot.Revision, - "status": "active", "result": result, "degraded": snapshot.Degraded, - } - if old.Active != nil { - extra["old_effective_tree_sha256"] = old.Active.EffectiveTreeSHA256 - } - if snapshot.Active != nil { - extra["upstream_source_id"] = snapshot.Active.UpstreamSourceID - extra["upstream_root"] = snapshot.Active.UpstreamRoot - extra["public_root"] = snapshot.Active.PublicRoot - extra["raw_tree_sha256"] = snapshot.Active.RawTreeSHA256 - extra["effective_tree_sha256"] = snapshot.Active.EffectiveTreeSHA256 - extra["file_count"] = snapshot.Active.FileCount - extra["raw_total_bytes"] = snapshot.Active.RawTotalBytes - extra["effective_total_bytes"] = snapshot.Active.EffectiveTotalBytes - extra["script_changes"] = snapshot.Active.ScriptChanges - extra["binary_changes"] = snapshot.Active.BinaryChanges - } - if snapshot.ActivePrompt != nil { - extra["prompt_raw_sha256"] = snapshot.ActivePrompt.RawSHA256 - extra["prompt_effective_sha256"] = snapshot.ActivePrompt.EffectiveSHA256 - } - middleware.SetAuditExtra(c, extra) - response.Success(c, snapshot) -} - -func parsePositiveID(c *gin.Context, name string) (int64, bool) { - id, err := strconv.ParseInt(strings.TrimSpace(c.Param(name)), 10, 64) - if err != nil || id <= 0 { - response.BadRequest(c, "Invalid "+name) - return 0, false - } - return id, true -} - -// List returns catalog metadata and the current runtime state. -func (h *SystemPromptHandler) List(c *gin.Context) { - items, err := h.service.ListTemplates(c.Request.Context()) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - snapshot, ok := h.service.CurrentSnapshot() - if !ok { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - response.Success(c, systemPromptEnvelope{Templates: items, Runtime: businessSystemPromptRuntimeResponse(snapshot)}) -} - -func (h *SystemPromptHandler) Runtime(c *gin.Context) { - snapshot, ok := h.service.CurrentSnapshot() - if !ok { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - response.Success(c, businessSystemPromptRuntimeResponse(snapshot)) -} - -func (h *SystemPromptHandler) Get(c *gin.Context) { - id, ok := parsePositiveID(c, "id") - if !ok { - return - } - detail, err := h.service.GetTemplate(c.Request.Context(), id) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - snapshot, ok := h.service.CurrentSnapshot() - if !ok { - writeBusinessSystemPromptError(c, service.ErrBusinessSystemPromptUnavailable) - return - } - response.Success(c, gin.H{"template": detail.Template, "versions": detail.Versions, "runtime": businessSystemPromptRuntimeResponse(snapshot)}) -} - -func (h *SystemPromptHandler) Versions(c *gin.Context) { - id, ok := parsePositiveID(c, "id") - if !ok { - return - } - detail, err := h.service.GetTemplate(c.Request.Context(), id) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, detail.Versions) -} - -func (h *SystemPromptHandler) Create(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - var req systemPromptCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - created, err := h.service.CreateTemplate(c.Request.Context(), service.BusinessSystemPromptTemplateCreate{ - Slug: req.Slug, Name: req.Name, Description: req.Description, Body: req.Body, Note: req.Note, - CompositionMode: req.CompositionMode, BundleID: req.BundleID, BundleManifestSHA256: req.BundleManifestSHA256, - }, actorID, req.ExpectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - if len(created.Versions) > 0 { - middleware.SetAuditExtra(c, map[string]any{ - "template_id": created.Template.ID, "template_version": created.Versions[0].Version, - "new_sha256": created.Versions[0].SHA256, "byte_length": created.Versions[0].ByteLength, - "composition_mode": created.Versions[0].CompositionMode, "bundle_id": created.Versions[0].BundleID, - "bundle_manifest_sha256": created.Versions[0].BundleManifestSHA256, - "revision": req.ExpectedRevision, "result": "created", - }) - } - response.Created(c, created) -} - -func (h *SystemPromptHandler) Update(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - id, ok := parsePositiveID(c, "id") - if !ok { - return - } - var req systemPromptUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - updated, err := h.service.UpdateTemplate(c.Request.Context(), id, service.BusinessSystemPromptTemplateUpdate{Name: req.Name, Description: req.Description}, actorID, req.ExpectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{"template_id": id, "revision": req.ExpectedRevision, "result": "updated"}) - response.Success(c, updated) -} - -func (h *SystemPromptHandler) SaveVersion(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - templateID, ok := parsePositiveID(c, "id") - if !ok { - return - } - var req systemPromptVersionRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - version, err := h.service.CreateVersionWithComposition(c.Request.Context(), templateID, service.BusinessSystemPromptVersionCreate{ - Body: req.Body, Note: req.Note, CompositionMode: req.CompositionMode, - BundleID: req.BundleID, BundleManifestSHA256: req.BundleManifestSHA256, - }, actorID, req.ExpectedLatestVersion, req.ExpectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": templateID, "template_version": version.Version, - "new_sha256": version.SHA256, "byte_length": version.ByteLength, - "composition_mode": version.CompositionMode, "bundle_id": version.BundleID, - "bundle_manifest_sha256": version.BundleManifestSHA256, - "revision": req.ExpectedRevision, "result": "draft_saved", - }) - response.Created(c, version) -} - -func (h *SystemPromptHandler) SyncManagedSource(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - templateID, ok := parsePositiveID(c, "id") - if !ok { - return - } - var req systemPromptSourceSyncRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - result, err := h.service.SyncManagedSource(c.Request.Context(), templateID, actorID, req.ExpectedLatestVersion, req.ExpectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - audit := map[string]any{ - "template_id": templateID, "expected_latest_version": req.ExpectedLatestVersion, - "revision": req.ExpectedRevision, "status": result.Status, - } - output := systemPromptSourceSyncResponse{Status: result.Status} - if result.Version != nil { - version := result.Version - output.Version = &systemPromptSourceSyncVersionResponse{ - ID: version.ID, TemplateID: version.TemplateID, Version: version.Version, - SHA256: version.SHA256, ByteLength: version.ByteLength, - SourceRepository: version.SourceRepository, SourceCommit: version.SourceCommit, - SourceVersion: version.SourceVersion, SourceArtifact: version.SourceArtifact, - SourceArtifactSHA256: version.SourceArtifactSHA256, SourceLicenseSHA256: version.SourceLicenseSHA256, - } - audit["template_version"] = version.Version - audit["source_commit"] = version.SourceCommit - audit["source_artifact_sha256"] = version.SourceArtifactSHA256 - } - middleware.SetAuditExtra(c, audit) - response.Success(c, output) -} - -func (h *SystemPromptHandler) Publish(c *gin.Context) { - h.publish(c, "publish", "published") -} - -func (h *SystemPromptHandler) Rollback(c *gin.Context) { - h.publish(c, "rollback", "rolled_back") -} - -func (h *SystemPromptHandler) publish(c *gin.Context, action, result string) { - actorID, ok := h.actorID(c) - if !ok { - return - } - templateID, ok := parsePositiveID(c, "id") - if !ok { - return - } - versionID, ok := parsePositiveID(c, "version_id") - if !ok { - return - } - var req struct { - ExpectedRevision int64 `json:"expected_revision"` - TargetRuleIDs []string `json:"target_rule_ids"` - } - if c.Request.ContentLength != 0 { - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - } - if req.ExpectedRevision == 0 { - req.ExpectedRevision, _ = strconv.ParseInt(strings.TrimSpace(c.Query("expected_revision")), 10, 64) - } - state, err := h.service.PublishVersionToRules(c.Request.Context(), templateID, versionID, req.ExpectedRevision, req.TargetRuleIDs, action, actorID) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": templateID, "version_id": versionID, - "rule_ids": req.TargetRuleIDs, "revision": state.Revision, "result": result, - }) - response.Success(c, state) -} - -func (h *SystemPromptHandler) Duplicate(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - sourceID, ok := parsePositiveID(c, "id") - if !ok { - return - } - var req systemPromptDuplicateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - created, err := h.service.DuplicateTemplate(c.Request.Context(), sourceID, req.Slug, req.Name, actorID, req.ExpectedRevision) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - extra := map[string]any{"template_id": created.Template.ID, "revision": req.ExpectedRevision, "result": "duplicated"} - if len(created.Versions) > 0 { - extra["template_version"] = created.Versions[0].Version - extra["new_sha256"] = created.Versions[0].SHA256 - extra["byte_length"] = created.Versions[0].ByteLength - extra["composition_mode"] = created.Versions[0].CompositionMode - extra["bundle_id"] = created.Versions[0].BundleID - extra["bundle_manifest_sha256"] = created.Versions[0].BundleManifestSHA256 - } - middleware.SetAuditExtra(c, extra) - response.Created(c, created) -} - -func (h *SystemPromptHandler) Delete(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - id, ok := parsePositiveID(c, "id") - if !ok { - return - } - expectedRevision, _ := strconv.ParseInt(strings.TrimSpace(c.Query("expected_revision")), 10, 64) - if err := h.service.DeleteTemplate(c.Request.Context(), id, actorID, expectedRevision); err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{"template_id": id, "revision": expectedRevision, "result": "deleted"}) - response.Success(c, gin.H{"deleted": true}) -} - -func (h *SystemPromptHandler) UpdateRuntime(c *gin.Context) { - actorID, ok := h.actorID(c) - if !ok { - return - } - var req systemPromptRuntimeRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - snapshot, err := h.service.UpdateRuntime(c.Request.Context(), service.BusinessSystemPromptRuntimeUpdate{ - ExpectedRevision: req.ExpectedRevision, Enabled: req.Enabled, - ExposeServerPrompt: req.ExposeServerPrompt, CompactEnabled: req.CompactEnabled, ActorID: actorID, - }) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": snapshot.TemplateID, "template_version": snapshot.TemplateVersion, - "new_sha256": snapshot.SHA256, "byte_length": snapshot.ByteLength, - "revision": snapshot.Revision, "enabled": snapshot.Enabled, - "expose_server_prompt": snapshot.ExposeServerPrompt, "compact_enabled": snapshot.CompactEnabled, - "composition_mode": snapshot.CompositionMode, "bundle_id": snapshot.BundleID, - "bundle_manifest_sha256": snapshot.BundleManifestSHA256, "degraded": snapshot.Degraded, - "result": "runtime_updated", - }) - response.Success(c, businessSystemPromptRuntimeResponse(snapshot)) -} - -func (h *SystemPromptHandler) PreviewMerge(c *gin.Context) { - var req systemPromptPreviewRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - server := req.ServerInstructions - var templateVersion int64 - composition := service.BusinessSystemPromptComposition{Mode: req.CompositionMode, BundleID: req.BundleID, BundleManifestSHA256: req.BundleManifestSHA256} - if server == "" && req.TemplateID > 0 { - detail, err := h.service.GetTemplate(c.Request.Context(), req.TemplateID) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - version, err := selectVersion(detail, req.VersionID) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - server = version.Body - templateVersion = version.Version - composition = service.BusinessSystemPromptComposition{Mode: version.CompositionMode, BundleID: version.BundleID, BundleManifestSHA256: version.BundleManifestSHA256} - } - hash, byteLength, err := service.ValidateBusinessSystemPromptBody(server) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": req.TemplateID, "template_version": templateVersion, - "new_sha256": hash, "byte_length": byteLength, "composition_mode": composition.Mode, - "bundle_id": composition.BundleID, "bundle_manifest_sha256": composition.BundleManifestSHA256, - "result": "previewed", - }) - clientMode, err := normalizeSystemPromptPreviewClientMode(req.ClientMode) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - prepared, err := h.service.PrepareBusinessSystemPromptPreviewSnapshotForClient(service.BusinessSystemPromptSnapshot{ - Enabled: true, Revision: 1, Body: server, SHA256: hash, ByteLength: byteLength, - CompositionMode: composition.Mode, BundleID: composition.BundleID, BundleManifestSHA256: composition.BundleManifestSHA256, - }, "", clientMode) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - _, application, err := service.ApplyBusinessSystemPromptToJSONContext(c.Request.Context(), []byte(`{"input":"preview"}`), prepared, - service.BusinessSystemPromptTarget{Platform: service.PlatformOpenAI, Protocol: service.BusinessSystemPromptProtocolResponses}) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": req.TemplateID, "template_version": templateVersion, - "new_sha256": hash, "byte_length": byteLength, "composition_mode": composition.Mode, - "bundle_id": composition.BundleID, "bundle_revision": application.BundleRevision, - "bundle_manifest_sha256": application.BundleManifestSHA256, "client_mode": clientMode, - "result": "previewed", - }) - response.Success(c, gin.H{ - "instructions": service.MergeBusinessSystemPromptInstructions(req.ClientInstructions, prepared.Body), - "client_mode": clientMode, "base_server_instructions": server, "final_server_instructions": prepared.Body, - "application": application, - }) -} - -func (h *SystemPromptHandler) PreviewUpstream(c *gin.Context) { - var req systemPromptPreviewRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - if len(req.Body) == 0 || !json.Valid(req.Body) { - response.BadRequest(c, "body must be valid JSON") - return - } - server := req.ServerInstructions - var versionID, templateVersion int64 - composition := service.BusinessSystemPromptComposition{Mode: req.CompositionMode, BundleID: req.BundleID, BundleManifestSHA256: req.BundleManifestSHA256} - if req.TemplateID > 0 { - detail, err := h.service.GetTemplate(c.Request.Context(), req.TemplateID) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - version, err := selectVersion(detail, req.VersionID) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - versionID = version.ID - templateVersion = version.Version - server = version.Body - composition = service.BusinessSystemPromptComposition{Mode: version.CompositionMode, BundleID: version.BundleID, BundleManifestSHA256: version.BundleManifestSHA256} - } - hash, byteLength, err := service.ValidateBusinessSystemPromptBody(server) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": req.TemplateID, "template_version": templateVersion, - "new_sha256": hash, "byte_length": byteLength, "composition_mode": composition.Mode, - "bundle_id": composition.BundleID, "bundle_manifest_sha256": composition.BundleManifestSHA256, - "result": "previewed", - }) - protocol := strings.ToLower(strings.TrimSpace(req.Protocol)) - if protocol == "" { - protocol = service.BusinessSystemPromptProtocolResponses - } - clientMode, err := normalizeSystemPromptPreviewClientMode(req.ClientMode) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - previewSnapshot, err := h.service.PrepareBusinessSystemPromptPreviewSnapshotForClient(service.BusinessSystemPromptSnapshot{ - Enabled: true, CompactEnabled: true, TemplateID: req.TemplateID, VersionID: versionID, TemplateVersion: templateVersion, - Revision: 1, Body: server, SHA256: hash, ByteLength: byteLength, - CompositionMode: composition.Mode, BundleID: composition.BundleID, BundleManifestSHA256: composition.BundleManifestSHA256, - }, "", clientMode) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - updated, application, err := service.ApplyBusinessSystemPromptToJSONContext(c.Request.Context(), req.Body, previewSnapshot, - service.BusinessSystemPromptTarget{Platform: service.PlatformOpenAI, Protocol: protocol, Compact: req.Compact}) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - var decoded any - if err := json.Unmarshal(updated, &decoded); err != nil { - response.BadRequest(c, "preview body could not be decoded") - return - } - middleware.SetAuditExtra(c, map[string]any{ - "template_id": req.TemplateID, "template_version": templateVersion, - "new_sha256": hash, "byte_length": byteLength, "composition_mode": composition.Mode, - "bundle_id": composition.BundleID, "bundle_revision": application.BundleRevision, - "bundle_manifest_sha256": application.BundleManifestSHA256, "client_mode": clientMode, - "result": "previewed", - }) - response.Success(c, gin.H{ - "body": decoded, "application": application, "client_mode": clientMode, - "base_server_instructions": server, "final_server_instructions": previewSnapshot.Body, - }) -} - -func normalizeSystemPromptPreviewClientMode(value string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "codex", nil - } - if value != "codex" && value != "openai_compatible" { - return "", service.ErrBusinessSystemPromptInvalid - } - return value, nil -} - -func selectVersion(detail service.BusinessSystemPromptTemplateDetail, versionID int64) (service.BusinessSystemPromptVersion, error) { - if len(detail.Versions) == 0 { - return service.BusinessSystemPromptVersion{}, service.ErrBusinessSystemPromptVersionNotFound - } - if versionID == 0 { - return detail.Versions[0], nil - } - for _, version := range detail.Versions { - if version.ID == versionID { - return version, nil - } - } - return service.BusinessSystemPromptVersion{}, service.ErrBusinessSystemPromptVersionNotFound -} diff --git a/backend/internal/handler/admin/system_prompt_handler_test.go b/backend/internal/handler/admin/system_prompt_handler_test.go index 5bde4f488a30..af496d3925fc 100644 --- a/backend/internal/handler/admin/system_prompt_handler_test.go +++ b/backend/internal/handler/admin/system_prompt_handler_test.go @@ -1,148 +1,24 @@ package admin import ( - "bytes" - "context" - "errors" - "mime/multipart" "net/http" "net/http/httptest" "testing" - "time" - "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" "github.com/tidwall/gjson" ) -func TestStartSkillSyncAcceptsMultipartPromptCaptureForFixedSource(t *testing.T) { +func TestRetiredPromptManagementHasNoExecutionPath(t *testing.T) { gin.SetMode(gin.TestMode) - files := service.NewRemoteSkillRegistryFilesystem(t.TempDir()) - seed, err := files.LoadSeed(context.Background()) - require.NoError(t, err) - store := &serviceTestRemoteSkillStore{job: service.RemoteSkillSyncJob{ID: 9, Status: service.RemoteSkillSyncStatusQueued, CreatedAt: time.Now()}} - registry := service.NewRemoteSkillRegistryService(store, nil, files, &serviceTestRemoteSkillSource{}) - require.NoError(t, registry.Start(context.Background())) - t.Cleanup(registry.Stop) - handler := NewSystemPromptHandler(nil, registry) - - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - require.NoError(t, writer.WriteField("expected_revision", "7")) - part, err := writer.CreateFormFile("prompt_capture", "capture.txt") - require.NoError(t, err) - _, err = part.Write([]byte(seed.Prompt.RawBody)) - require.NoError(t, err) - require.NoError(t, writer.Close()) - recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/system-prompts/skill-registry/syncs", &requestBody) - ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) - ctx.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42}) - handler.StartSkillSync(ctx) - - require.Equal(t, http.StatusAccepted, recorder.Code) - require.Equal(t, int64(42), store.actorID) - require.Equal(t, int64(7), store.expectedRevision) - require.True(t, store.promptProvided) -} - -func TestSkillVersionDetailIncludesBodiesWithoutLeakingIntoRegistrySummary(t *testing.T) { - gin.SetMode(gin.TestMode) - files := service.NewRemoteSkillRegistryFilesystem(t.TempDir()) - seed, err := files.LoadSeed(context.Background()) - require.NoError(t, err) - store := &serviceTestRemoteSkillStore{} - registry := service.NewRemoteSkillRegistryService(store, nil, files, &serviceTestRemoteSkillSource{}) - require.NoError(t, registry.Start(context.Background())) - t.Cleanup(registry.Stop) - handler := NewSystemPromptHandler(nil, registry) - - detailRecorder := httptest.NewRecorder() - detailContext, _ := gin.CreateTestContext(detailRecorder) - detailContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/system-prompts/skill-registry/versions/1", nil) - detailContext.Params = gin.Params{{Key: "bundle_version_id", Value: "1"}} - handler.SkillVersion(detailContext) - - require.Equal(t, http.StatusOK, detailRecorder.Code) - require.Equal(t, seed.Prompt.RawBody, gjson.Get(detailRecorder.Body.String(), "data.prompt.raw_body").String()) - require.Equal(t, seed.Prompt.EffectiveBody, gjson.Get(detailRecorder.Body.String(), "data.prompt.effective_body").String()) - require.Equal(t, seed.Prompt.RawSHA256, gjson.Get(detailRecorder.Body.String(), "data.prompt.raw_sha256").String()) - require.Equal(t, seed.Prompt.EffectiveSHA256, gjson.Get(detailRecorder.Body.String(), "data.prompt.effective_sha256").String()) - - summaryRecorder := httptest.NewRecorder() - summaryContext, _ := gin.CreateTestContext(summaryRecorder) - summaryContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/system-prompts/skill-registry", nil) - handler.SkillRegistry(summaryContext) - - require.Equal(t, http.StatusOK, summaryRecorder.Code) - require.False(t, gjson.Get(summaryRecorder.Body.String(), "data.runtime.active_prompt.raw_body").Exists()) - require.False(t, gjson.Get(summaryRecorder.Body.String(), "data.runtime.active_prompt.effective_body").Exists()) -} - -type serviceTestRemoteSkillStore struct { - snapshot service.RemoteSkillRegistrySnapshot - detail service.RemoteSkillBundleVersionDetail - job service.RemoteSkillSyncJob - actorID int64 - expectedRevision int64 - promptProvided bool -} - -func (s *serviceTestRemoteSkillStore) EnsureRemoteSkillSeed(_ context.Context, candidate service.RemoteSkillCandidate) (service.RemoteSkillRegistrySnapshot, error) { - candidate.Version.ID = 1 - candidate.Prompt.ID = 1 - candidate.Version.PromptVersionID = 1 - s.detail = service.RemoteSkillBundleVersionDetail{ - RemoteSkillBundleVersion: candidate.Version, - Prompt: candidate.Prompt, - FileChanges: candidate.FileChanges, - } - s.snapshot = service.RemoteSkillRegistrySnapshot{ - Revision: 7, Active: &candidate.Version, ActivePrompt: &candidate.Prompt, UpdatedAt: time.Now().UTC(), - } - return s.snapshot, nil -} -func (s *serviceTestRemoteSkillStore) LoadRemoteSkillSnapshot(context.Context) (service.RemoteSkillRegistrySnapshot, error) { - return s.snapshot, nil -} -func (s *serviceTestRemoteSkillStore) ListRemoteSkillVersions(context.Context) ([]service.RemoteSkillBundleVersion, error) { - return nil, nil -} -func (s *serviceTestRemoteSkillStore) GetRemoteSkillVersion(context.Context, int64) (service.RemoteSkillBundleVersionDetail, error) { - return s.detail, nil -} -func (s *serviceTestRemoteSkillStore) CreateRemoteSkillSyncJob(_ context.Context, actorID, expectedRevision int64, promptProvided bool) (service.RemoteSkillSyncJob, error) { - s.actorID, s.expectedRevision, s.promptProvided = actorID, expectedRevision, promptProvided - return s.job, nil -} -func (s *serviceTestRemoteSkillStore) UpdateRemoteSkillSyncJobStage(context.Context, int64, string) error { - return nil -} -func (s *serviceTestRemoteSkillStore) ExpireRemoteSkillSyncJobs(context.Context) error { return nil } -func (s *serviceTestRemoteSkillStore) CompleteRemoteSkillSyncJob(context.Context, int64, service.RemoteSkillCandidate) (service.RemoteSkillSyncJob, error) { - return s.job, nil -} -func (s *serviceTestRemoteSkillStore) FailRemoteSkillSyncJob(context.Context, int64, string) error { - return nil -} -func (s *serviceTestRemoteSkillStore) GetRemoteSkillSyncJob(context.Context, int64) (service.RemoteSkillSyncJob, error) { - return s.job, nil -} -func (s *serviceTestRemoteSkillStore) PublishRemoteSkillVersion(context.Context, int64, int64, int64) (service.RemoteSkillRegistrySnapshot, error) { - return s.snapshot, nil -} -func (s *serviceTestRemoteSkillStore) CleanupLegacyRemoteSkillData(context.Context) error { - return nil -} - -type serviceTestRemoteSkillSource struct{} - -func (*serviceTestRemoteSkillSource) Build(context.Context, service.RemoteSkillPromptCapture, *service.RemoteSkillCandidate) (service.RemoteSkillCandidate, error) { - return service.RemoteSkillCandidate{}, errors.New("stop after handler contract") + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/system-prompts/skill-registry/syncs", nil) + NewSystemPromptHandler(nil).Retired(ctx) + require.Equal(t, http.StatusGone, recorder.Code) + require.Equal(t, "system_prompt_management_retired", gjson.Get(recorder.Body.String(), "reason").String()) } func TestWriteBusinessSystemPromptErrorUsesStableProtocolCodes(t *testing.T) { @@ -195,33 +71,3 @@ func TestWriteBusinessSystemPromptErrorUsesStableProtocolCodes(t *testing.T) { }) } } - -func TestBusinessSystemPromptRuntimeResponseIncludesActiveVersionAndStatus(t *testing.T) { - updatedAt := time.Date(2026, 8, 6, 5, 4, 3, 0, time.UTC) - got := businessSystemPromptRuntimeResponse(service.BusinessSystemPromptSnapshot{ - Enabled: true, ExposeServerPrompt: false, CompactEnabled: true, - TemplateID: 11, VersionID: 22, TemplateVersion: 3, Revision: 9, - SHA256: "ABCDEF", ByteLength: 123, Degraded: true, UpdatedAt: updatedAt, - }) - require.Equal(t, int64(3), got.TemplateVersion) - require.Equal(t, int64(9), got.Revision) - require.Equal(t, "abcdef", got.SHA256) - require.Equal(t, 123, got.ByteLength) - require.True(t, got.Degraded) - require.Equal(t, updatedAt, got.UpdatedAt) -} - -func TestSelectBusinessSystemPromptVersionUsesLatestOrExplicitID(t *testing.T) { - detail := service.BusinessSystemPromptTemplateDetail{Versions: []service.BusinessSystemPromptVersion{ - {ID: 8, Version: 2, Body: "latest"}, - {ID: 4, Version: 1, Body: "old"}, - }} - latest, err := selectVersion(detail, 0) - require.NoError(t, err) - require.Equal(t, int64(2), latest.Version) - explicit, err := selectVersion(detail, 4) - require.NoError(t, err) - require.Equal(t, "old", explicit.Body) - _, err = selectVersion(detail, 99) - require.ErrorIs(t, err, service.ErrBusinessSystemPromptVersionNotFound) -} diff --git a/backend/internal/handler/admin/system_prompt_rules_handler.go b/backend/internal/handler/admin/system_prompt_rules_handler.go index 9aa3001979d6..4cbf2c0e435a 100644 --- a/backend/internal/handler/admin/system_prompt_rules_handler.go +++ b/backend/internal/handler/admin/system_prompt_rules_handler.go @@ -1,9 +1,6 @@ package admin import ( - "strconv" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -19,28 +16,6 @@ func (h *SystemPromptHandler) Rules(c *gin.Context) { response.Success(c, state) } -func (h *SystemPromptHandler) UpdateRules(c *gin.Context) { - var request struct { - ExpectedRevision int64 `json:"expected_revision" binding:"required"` - Policy extensionv1.PromptRulePolicy `json:"policy" binding:"required"` - } - if err := c.ShouldBindJSON(&request); err != nil { - response.BadRequest(c, "Invalid prompt rule policy") - return - } - actor, ok := h.actorID(c) - if !ok { - return - } - state, err := h.service.UpdatePromptRules(c.Request.Context(), request.Policy, request.ExpectedRevision, actor) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - middleware.SetAuditExtra(c, map[string]any{"revision": state.Revision, "rule_count": len(state.Policy.Rules), "result": "rules_updated"}) - response.Success(c, state) -} - func (h *SystemPromptHandler) AccountBindings(c *gin.Context) { var request struct { AccountIDs []int64 `json:"account_ids" binding:"required"` @@ -74,22 +49,3 @@ func (h *SystemPromptHandler) UpdateAccountBindings(c *gin.Context) { middleware.SetAuditExtra(c, map[string]any{"account_count": len(results), "result": "bindings_updated"}) response.Success(c, results) } - -func (h *SystemPromptHandler) PreviewRules(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("account_id"), 10, 64) - if err != nil || id < 1 { - response.BadRequest(c, "Invalid account") - return - } - var request service.PromptRulesPreviewRequest - if err := c.ShouldBindJSON(&request); err != nil { - response.BadRequest(c, "Invalid prompt preview") - return - } - result, err := h.service.PreviewPromptRules(c.Request.Context(), id, request) - if err != nil { - writeBusinessSystemPromptError(c, err) - return - } - response.Success(c, result) -} diff --git a/backend/internal/handler/remote_skill_handler.go b/backend/internal/handler/remote_skill_handler.go index b27b37c6d651..fb4247c60ea9 100644 --- a/backend/internal/handler/remote_skill_handler.go +++ b/backend/internal/handler/remote_skill_handler.go @@ -23,8 +23,8 @@ func NewRemoteSkillHandler(reader RemoteSkillPublicReader) *RemoteSkillHandler { return &RemoteSkillHandler{reader: reader} } -func ProvideRemoteSkillHandler(registry *service.RemoteSkillRegistryService) *RemoteSkillHandler { - return NewRemoteSkillHandler(registry) +func ProvideRemoteSkillHandler(files *service.FrozenPromptFiles) *RemoteSkillHandler { + return NewRemoteSkillHandler(files) } func (h *RemoteSkillHandler) Serve(c *gin.Context) { diff --git a/backend/internal/nativeapi/account_tools.go b/backend/internal/nativeapi/account_tools.go index 2a4e03eb7995..3ee2aa85c5f2 100644 --- a/backend/internal/nativeapi/account_tools.go +++ b/backend/internal/nativeapi/account_tools.go @@ -35,7 +35,8 @@ type TextPromptSelection struct { type BatchTestSelection struct { AccountID int64 `json:"account_id"` - ModelID string `json:"model_id"` + SelectionMode string `json:"selection_mode,omitempty"` + ModelID string `json:"model_id,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` } type BatchTestPlanningRequest struct { diff --git a/backend/internal/proxytransport/address.go b/backend/internal/proxytransport/address.go index a78c46ced3e2..7b025ad855bc 100644 --- a/backend/internal/proxytransport/address.go +++ b/backend/internal/proxytransport/address.go @@ -1,105 +1,651 @@ package proxytransport import ( + "crypto/sha256" + "encoding/hex" "errors" + "fmt" "net" "net/url" "strconv" "strings" + "unicode" ) -// Normalize accepts the existing URL, host:port:user:password and labelled -// multiline forms. It performs no DNS/network IO and never echoes credentials. -func Normalize(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil +const parserVersion = "ticket-proxy-v2" + +// Candidate deliberately has no exported credential or full address field. +// Selection is resolved by parsing the original draft again, never a client URL. +type Candidate struct { + SelectionID string `json:"selection_id"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + UsernameMasked string `json:"username_masked,omitempty"` + SourceLine int `json:"source_line"` + SourceColumn int `json:"source_column"` + Format string `json:"format"` + endpoint string + identity string +} + +type Issue struct { + Line int `json:"line"` + Column int `json:"column"` + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` +} + +type ParseResult struct { + Candidates []Candidate `json:"candidates"` + Issues []Issue `json:"issues"` + SelectionID string `json:"selection_id,omitempty"` + SelectionRequired bool `json:"selection_required"` +} + +// SelectionError is safe to expose: neither its text nor its issues echo input. +type SelectionError struct { + Code string + Result ParseResult +} + +func (e *SelectionError) Error() string { + switch e.Code { + case "proxy_selection_required": + return "Select one recognized proxy before saving or testing" + case "proxy_selection_stale": + return "The proxy selection no longer matches this input; parse and select again" + default: + if len(e.Result.Issues) > 0 { + issue := e.Result.Issues[0] + return fmt.Sprintf("Line %d, column %d: %s", issue.Line, issue.Column, issue.Message) + } + return "Enter a valid proxy address" } +} + +func validScheme(s string) bool { + return s == "http" || s == "https" || s == "socks5" || s == "socks5h" +} + +// ParseEndpoint accepts only a complete endpoint; transport code must not guess +// formats, apply protocol hints, or rewrite host casing and explicit ports. +func ParseEndpoint(raw string) (*url.URL, error) { + if raw == "" || strings.TrimSpace(raw) != raw || len(raw) > 16384 { + return nil, errors.New("proxy endpoint is required") + } + u, err := url.Parse(raw) + if err != nil || u == nil || !validScheme(u.Scheme) || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || (u.Path != "" && u.Path != "/") { + return nil, errors.New("proxy URL requires HTTP, HTTPS, SOCKS5 or SOCKS5H and no path, query or fragment") + } + host := u.Hostname() + if !validHost(host) { + return nil, errors.New("proxy host is missing or invalid; enclose IPv6 addresses in brackets") + } + if strings.Contains(host, ":") && !strings.HasPrefix(u.Host, "[") { + return nil, errors.New("enclose IPv6 addresses in brackets") + } + if port := u.Port(); port != "" { + if _, err := parsePort(port); err != nil { + return nil, err + } + } else if strings.HasSuffix(u.Host, ":") { + return nil, errors.New("proxy port must be between 1 and 65535") + } + return u, nil +} + +func validHost(host string) bool { + if host == "" { + return false + } + if ip, zone, hasZone := strings.Cut(host, "%"); net.ParseIP(ip) != nil { + return !hasZone || zone != "" && !strings.ContainsAny(zone, " /\\@[]:%\t\r\n") + } + for _, ch := range host { + if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && ch != '-' && ch != '.' && ch != '_' { + return false + } + } + return !strings.HasPrefix(host, ".") && !strings.Contains(host, "..") +} + +func parsePort(value string) (int, error) { + for _, r := range value { + if r < '0' || r > '9' { + return 0, errors.New("proxy port must be between 1 and 65535") + } + } + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 65535 { + return 0, errors.New("proxy port must be between 1 and 65535") + } + return n, nil +} + +func effectivePort(u *url.URL) int { + if p, err := strconv.Atoi(u.Port()); err == nil { + return p + } + if u.Scheme == "http" { + return 80 + } + if u.Scheme == "https" { + return 443 + } + return 1080 +} + +// Parse is pure, bounded, and credential-safe. Independent grammars contribute +// candidates; a valid record is not lost because another record is malformed. +func Parse(raw, protocol string) ParseResult { + protocolInput := protocol + result := ParseResult{Candidates: []Candidate{}, Issues: []Issue{}} if len(raw) > 16384 { - return "", errors.New("proxy input is too long") + result.Issues = append(result.Issues, Issue{1, 1, "input", "input_too_long", "Proxy input must be at most 16384 bytes"}) + return result + } + protocol = strings.ToLower(strings.TrimSpace(protocol)) + if protocol == "" { + protocol = "http" } - if strings.ContainsAny(raw, "\r\n") { - fields := map[string]string{} - for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { - line = strings.TrimSpace(line) - if line == "" || line == "(" || line == ")" { + if !validScheme(protocol) { + result.Issues = append(result.Issues, Issue{1, 1, "protocol", "unsupported_protocol", "Default protocol must be http, https, socks5 or socks5h"}) + return result + } + seen := map[string]bool{} + add := func(items []Candidate) { + for _, item := range items { + if seen[item.identity] { continue } - i := strings.IndexAny(line, "::=") - if i < 0 { - return "", errors.New("each proxy field needs a label") + seen[item.identity] = true + sum := sha256.Sum256([]byte(parserVersion + "\x00" + raw + "\x00" + protocolInput + "\x00" + item.identity)) + item.SelectionID = hex.EncodeToString(sum[:]) + result.Candidates = append(result.Candidates, item) + } + } + var pending []labelValue + pendingLine := 0 + flush := func() { + if len(pending) == 0 { + return + } + items, issues := parseLabels(pending, protocol, pendingLine) + add(items) + result.Issues = append(result.Issues, issues...) + pending = nil + } + lines := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + for i, line := range strings.Split(lines, "\n") { + if strings.TrimSpace(line) == "" { + flush() + continue + } + if strings.TrimSpace(line) == "(" || strings.TrimSpace(line) == ")" { + continue + } + items, issue := parseRecord(line, protocol, i+1) + labels := readLabels(line, i+1) + authenticatedRecord := false + for _, item := range items { + authenticatedRecord = authenticatedRecord || item.UsernameMasked != "" + } + if len(labels) > 0 && (len(items) == 0 || !authenticatedRecord && (len(pending) > 0 || labels[0].key != "host")) { + var host, port bool + for _, label := range labels { + host = host || label.key == "host" + port = port || label.key == "port" + } + var previousHost, previousPort bool + for _, label := range pending { + previousHost = previousHost || label.key == "host" + previousPort = previousPort || label.key == "port" } - key := strings.ToLower(strings.TrimSpace(line[:i])) - value := strings.TrimSpace(line[i+len(string([]rune(line[i:])[0])):]) - switch key { - case "proxy server", "proxy_server", "server", "host", "hostname", "主机", "服务器", "节点", "代理服务器": - key = "host" - case "port", "端口": - key = "port" - case "user", "username", "用户名", "账号", "帐号": - key = "username" - case "pass", "password", "密码": - key = "password" - case "scheme", "protocol", "协议": - key = "scheme" - default: - return "", errors.New("unrecognized proxy field label") + if host && port && previousHost && previousPort { + flush() } - if _, exists := fields[key]; exists { - return "", errors.New("duplicate proxy field") + if len(pending) == 0 { + pendingLine = i + 1 } - fields[key] = value + pending = append(pending, labels...) + continue } - if fields["host"] == "" || fields["port"] == "" { - return "", errors.New("proxy host and port are required") + flush() + if len(items) > 0 { + add(items) + continue } - scheme := fields["scheme"] - if scheme == "" { - scheme = "http" + // Split multiple complete records only if no whole-record grammar + // succeeded, so punctuation in an ordinary password stays literal. + parts, splitErr := splitFields(line, columnDelimiter, true) + if splitErr == nil && len(parts) > 1 { + offset := 0 + for _, part := range parts { + if at := strings.Index(line[offset:], part); at >= 0 { + offset += at + } + column := len([]rune(line[:offset])) + 1 + items, issue := parseRecord(part, protocol, i+1) + for n := range items { + items[n].SourceColumn = column + } + add(items) + if len(items) == 0 { + issue.Column = column + result.Issues = append(result.Issues, issue) + } + offset = min(len(line), offset+len(part)) + } + } else { + result.Issues = append(result.Issues, issue) } - address := url.URL{Scheme: strings.ToLower(scheme), Host: net.JoinHostPort(strings.Trim(fields["host"], "[]"), fields["port"])} - _, user := fields["username"] - _, password := fields["password"] - if user != password { - return "", errors.New("both username and password are required") + } + flush() + if len(result.Candidates) == 1 { + result.SelectionID = result.Candidates[0].SelectionID + } + result.SelectionRequired = len(result.Candidates) > 1 + return result +} + +// Resolve reuses the same parser for save and test. Even a unique candidate +// rejects a supplied stale selection. An empty draft is allowed only unselected. +func Resolve(raw, protocol, selectionID string) (string, error) { + result := Parse(raw, protocol) + if selectionID != "" { + for _, candidate := range result.Candidates { + if candidate.SelectionID == selectionID { + return candidate.endpoint, nil + } } - if user { - address.User = url.UserPassword(fields["username"], fields["password"]) + return "", &SelectionError{Code: "proxy_selection_stale", Result: result} + } + if result.SelectionRequired { + return "", &SelectionError{Code: "proxy_selection_required", Result: result} + } + if len(result.Candidates) == 1 { + return result.Candidates[0].endpoint, nil + } + if strings.TrimSpace(raw) == "" && len(result.Issues) == 0 { + return "", nil + } + return "", &SelectionError{Code: "invalid_proxy", Result: result} +} + +func Normalize(raw string) (string, error) { return Resolve(raw, "", "") } + +func candidate(raw, format string, line int) (Candidate, error) { + u, err := ParseEndpoint(raw) + if err != nil { + return Candidate{}, err + } + user, password, present := "", "", false + if u.User != nil { + user = u.User.Username() + password, present = u.User.Password() + } + // Length-prefixed credentials prevent separator collisions during dedupe. + hostIdentity := strings.ToLower(u.Hostname()) + if host, zone, zoned := strings.Cut(u.Hostname(), "%"); net.ParseIP(host) != nil { + hostIdentity = net.ParseIP(host).String() + if zoned { + hostIdentity += "%" + zone } - raw = address.String() - } else if !strings.Contains(raw, "://") { - if strings.Contains(raw, "@") { - raw = "http://" + raw - } else { - parts := strings.SplitN(raw, ":", 4) - if len(parts) == 4 && !strings.HasPrefix(raw, "[") { - if _, err := strconv.Atoi(parts[1]); err != nil { - return "", errors.New("ambiguous proxy fields; use labelled fields") - } - address := url.URL{Scheme: "http", Host: net.JoinHostPort(parts[0], parts[1]), User: url.UserPassword(parts[2], parts[3])} - raw = address.String() - } else { - raw = "http://" + raw + } + identity := fmt.Sprintf("%s|%s|%d|%t|%d:%s|%t|%d:%s", u.Scheme, hostIdentity, effectivePort(u), u.User != nil, len(user), user, present, len(password), password) + masked := "" + if u.User != nil { + masked = "***" + } + return Candidate{Protocol: u.Scheme, Host: u.Hostname(), Port: effectivePort(u), UsernameMasked: masked, SourceLine: line, SourceColumn: 1, Format: format, endpoint: raw, identity: identity}, nil +} + +func fieldsCandidate(fields []string, protocol, format string, line int, reverse bool) (Candidate, error) { + host, port := fields[0], fields[1] + username, password := "", "" + if len(fields) == 4 { + username, password = fields[2], fields[3] + if reverse { + username, password, host, port = fields[0], fields[1], fields[2], fields[3] + } + } + if _, err := parsePort(port); err != nil { + return Candidate{}, err + } + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + if !validHost(host) { + return Candidate{}, errors.New("proxy host is missing or invalid") + } + u := &url.URL{Scheme: protocol, Host: net.JoinHostPort(host, port)} + if len(fields) == 4 { + u.User = url.UserPassword(username, password) + } + return candidate(u.String(), format, line) +} + +func parseRecord(raw, protocol string, line int) ([]Candidate, Issue) { + raw = strings.TrimSpace(raw) + items := []Candidate{} + issue := Issue{line, 1, "record", "invalid_record", "Expected a proxy URL, host and port, authenticated fields, or labelled fields"} + if net.ParseIP(raw) != nil { + issue.Field, issue.Code, issue.Message = "host", "ipv6_brackets_required", "Enclose IPv6 addresses in brackets and specify a port" + return items, issue + } + add := func(item Candidate, err error) { + if err == nil { + items = append(items, item) + } + } + if hasURIPrefix(raw) { + item, err := candidate(raw, "uri", line) + add(item, err) + if err != nil { + issue.Message = err.Error() + } + return items, issue + } + // URI userinfo is decoded exactly once by net/url; field grammars below + // treat credentials literally, including percent signs and internal spaces. + if u, err := ParseEndpoint(protocol + "://" + raw); err == nil && u.Port() != "" { + format := "host:port" + if u.User != nil { + format = "username:password@host:port" + } + add(candidate(protocol+"://"+raw, format, line)) + } + colon, err := splitFields(raw, func(r rune) bool { return r == ':' }, false) + if err == nil { + if len(colon) == 2 { + add(fieldsCandidate(colon, protocol, "host:port", line, false)) + } + if len(colon) >= 4 { + add(fieldsCandidate([]string{colon[0], colon[1], colon[2], strings.Join(colon[3:], ":")}, protocol, "host:port:username:password", line, false)) + add(fieldsCandidate([]string{colon[0], strings.Join(colon[1:len(colon)-2], ":"), colon[len(colon)-2], colon[len(colon)-1]}, protocol, "username:password:host:port", line, true)) + } + } + for _, at := range delimiters(raw, func(r rune) bool { return r == '@' }) { + hp, e1 := splitFields(raw[:at], func(r rune) bool { return r == ':' }, false) + up, e2 := splitFields(raw[at+1:], func(r rune) bool { return r == ':' }, false) + if e1 == nil && e2 == nil && len(hp) == 2 && len(up) >= 2 { + add(fieldsCandidate([]string{hp[0], hp[1], up[0], strings.Join(up[1:], ":")}, protocol, "host:port@username:password", line, false)) + } + if e1 == nil && e2 == nil && len(hp) >= 2 && len(up) == 2 && hasQuotedField(raw[:at]) { + add(fieldsCandidate([]string{up[0], up[1], hp[0], strings.Join(hp[1:], ":")}, protocol, "quoted username:password@host:port", line, false)) + } + } + columns, err := splitFields(raw, columnDelimiter, true) + if err == nil && (len(columns) == 2 || len(columns) == 4) { + add(fieldsCandidate(columns, protocol, "host port username password", line, false)) + if len(columns) == 4 { + add(fieldsCandidate(columns, protocol, "username password host port", line, true)) + } + } + if err != nil { + issue.Code, issue.Message = "unclosed_quote", "Close the quoted proxy field" + } + if len(colon) == 2 && validHost(strings.Trim(colon[0], "[]")) { + if _, err := parsePort(colon[1]); err != nil { + issue.Field, issue.Code, issue.Message = "port", "invalid_port", err.Error() + } + } + return items, issue +} + +func hasURIPrefix(raw string) bool { + end := strings.Index(raw, "://") + if end <= 0 { + return false + } + for i, r := range raw[:end] { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { + continue + } + if i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || r == '.') { + continue + } + return false + } + return true +} + +func hasQuotedField(raw string) bool { + starts := []int{0} + for _, position := range delimiters(raw, func(r rune) bool { return r == ':' }) { + starts = append(starts, position+1) + } + for _, start := range starts { + field := strings.TrimSpace(raw[start:]) + if strings.HasPrefix(field, "\"") || strings.HasPrefix(field, "'") { + return true + } + } + return false +} + +func columnDelimiter(r rune) bool { return unicode.IsSpace(r) || r == ',' || r == '|' || r == ';' } + +// delimiters ignores delimiters inside quoted fields and bracketed IPv6 hosts. +func delimiters(raw string, delimiter func(rune) bool) []int { + var positions []int + var quote rune + brackets, escaped := 0, false + for i, r := range raw { + if quote != 0 { + if escaped { + escaped = false + continue } + if r == '\\' { + escaped = true + continue + } + if r == quote { + quote = 0 + } + continue + } + if r == '\'' || r == '"' { + quote = r + continue + } + if r == '[' { + brackets++ + } + if r == ']' && brackets > 0 { + brackets-- + } + if brackets == 0 && delimiter(r) { + positions = append(positions, i) } } - address, err := url.Parse(raw) - if err != nil || address == nil { - return "", errors.New("invalid proxy URL") + return positions +} + +func fieldValue(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil } - address.Scheme = strings.ToLower(address.Scheme) - if address.Hostname() == "" || strings.ContainsAny(address.Hostname(), " \t\r\n") || address.Opaque != "" || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || (address.Path != "" && address.Path != "/") { - return "", errors.New("proxy URL requires a host and no path, query or fragment") + if raw[0] != '\'' && raw[0] != '"' { + return raw, nil } - switch address.Scheme { - case "http", "https", "socks5", "socks5h": - default: - return "", errors.New("unsupported proxy protocol") + quote := raw[0] + if len(raw) < 2 || raw[len(raw)-1] != quote { + return "", errors.New("unclosed quoted field") + } + value := raw[1 : len(raw)-1] + value = strings.ReplaceAll(value, "\\"+string(quote), string(quote)) + return value, nil +} + +func splitFields(raw string, delimiter func(rune) bool, collapse bool) ([]string, error) { + positions := delimiters(raw, delimiter) + values := []string{} + start := 0 + for _, at := range append(positions, len(raw)) { + part := raw[start:at] + value, err := fieldValue(part) + if err != nil { + return nil, err + } + if !collapse || value != "" || strings.HasPrefix(strings.TrimSpace(part), "\"") || strings.HasPrefix(strings.TrimSpace(part), "'") { + values = append(values, value) + } + if at < len(raw) { + _, width := runeAt(raw[at:]) + start = at + width + } + } + return values, nil +} + +func runeAt(raw string) (rune, int) { + for _, r := range raw { + return r, len(string(r)) + } + return 0, 0 +} + +type labelValue struct { + key, value string + line, column int +} + +var labelNames = map[string]string{ + "proxy server": "host", "proxy_server": "host", "server": "host", "host": "host", "hostname": "host", "address": "host", "ip": "host", "主机": "host", "服务器": "host", "节点": "host", "代理服务器": "host", "代理地址": "host", "地址": "host", + "port": "port", "端口": "port", "user": "username", "username": "username", "用户名": "username", "账号": "username", "帐号": "username", + "pass": "password", "password": "password", "密码": "password", "scheme": "scheme", "protocol": "scheme", "协议": "scheme", +} + +func readLabels(raw string, line int) []labelValue { + // Quotes protect a password containing text such as "port: 8080" from + // becoming another field. Colons and punctuation elsewhere stay untouched. + starts := append([]int{0}, delimiters(raw, columnDelimiter)...) + type found struct { + start, value int + key string + } + var labels []found + for _, position := range starts { + if position > 0 { + _, n := runeAt(raw[position:]) + position += n + } + rest := strings.TrimLeftFunc(raw[position:], unicode.IsSpace) + position = len(raw) - len(rest) + if len(labels) > 0 && position < labels[len(labels)-1].value { + continue + } + at := strings.IndexAny(rest, "::=") + if at < 0 { + continue + } + key, ok := labelNames[strings.ToLower(strings.TrimSpace(rest[:at]))] + if !ok { + continue + } + _, width := runeAt(rest[at:]) + if len(labels) > 0 && labels[len(labels)-1].start == position { + continue + } + labels = append(labels, found{position, position + at + width, key}) + } + if len(labels) == 0 || strings.TrimSpace(raw[:labels[0].start]) != "" { + return nil + } + result := make([]labelValue, 0, len(labels)) + for i, label := range labels { + end := len(raw) + if i+1 < len(labels) { + end = labels[i+1].start + } + value := raw[label.value:end] + if i+1 < len(labels) { + value = strings.TrimRightFunc(value, columnDelimiter) + } + result = append(result, labelValue{label.key, value, line, len([]rune(raw[:label.start])) + 1}) + } + return result +} + +func parseLabels(labels []labelValue, protocol string, line int) ([]Candidate, []Issue) { + fields := map[string]string{} + issues := []Issue{} + for _, label := range labels { + value, err := fieldValue(label.value) + if err != nil { + issues = append(issues, Issue{label.line, label.column, label.key, "unclosed_quote", "Close the quoted proxy field"}) + continue + } + if previous, exists := fields[label.key]; exists && previous != value { + issues = append(issues, Issue{label.line, label.column, label.key, "conflicting_field", "Explicit values for the same proxy field conflict"}) + } + fields[label.key] = value + } + if len(issues) > 0 { + return nil, issues + } + fail := func(field, code, message string) ([]Candidate, []Issue) { + return nil, []Issue{{line, 1, field, code, message}} + } + host := fields["host"] + if host == "" { + return fail("host", "missing_host", "Proxy host is required") + } + if explicit := strings.ToLower(fields["scheme"]); explicit != "" { + if !validScheme(explicit) { + return fail("scheme", "unsupported_protocol", "Protocol must be http, https, socks5 or socks5h") + } + protocol = explicit + } + var u *url.URL + var err error + if strings.Contains(host, "://") { + u, err = ParseEndpoint(host) + if err != nil { + return fail("host", "invalid_endpoint", err.Error()) + } + if explicit := strings.ToLower(fields["scheme"]); explicit != "" && explicit != u.Scheme { + return fail("scheme", "conflicting_field", "The explicit protocol conflicts with the address protocol") + } + } else { + hostPort, splitErr := splitFields(host, func(r rune) bool { return r == ':' }, false) + if splitErr == nil && len(hostPort) == 2 { + u = &url.URL{Scheme: protocol, Host: net.JoinHostPort(strings.Trim(hostPort[0], "[]"), hostPort[1])} + } else { + u = &url.URL{Scheme: protocol, Host: host} + } + } + if port := fields["port"]; port != "" { + if _, err := parsePort(port); err != nil { + return fail("port", "invalid_port", err.Error()) + } + if u.Port() != "" && u.Port() != port { + return fail("port", "conflicting_field", "The explicit port conflicts with the address port") + } + u.Host = net.JoinHostPort(u.Hostname(), port) + } else if u.Port() == "" && !strings.Contains(host, "://") { + return fail("port", "missing_port", "Proxy port is required") + } + _, userPresent := fields["username"] + _, passwordPresent := fields["password"] + if userPresent != passwordPresent { + return fail("credentials", "incomplete_credentials", "Both username and password fields are required") + } + if userPresent { + if u.User != nil { + password, present := u.User.Password() + if u.User.Username() != fields["username"] || !present || password != fields["password"] { + return fail("credentials", "conflicting_field", "Explicit credentials conflict with the address credentials") + } + } + u.User = url.UserPassword(fields["username"], fields["password"]) } - port, err := strconv.Atoi(address.Port()) - if err != nil || port < 1 || port > 65535 { - return "", errors.New("proxy port must be between 1 and 65535") + item, err := candidate(u.String(), "labelled fields", line) + if err != nil { + return fail("record", "invalid_record", err.Error()) } - address.Path = "" - return address.String(), nil + return []Candidate{item}, nil } diff --git a/backend/internal/proxytransport/address_test.go b/backend/internal/proxytransport/address_test.go new file mode 100644 index 000000000000..222606cce6bc --- /dev/null +++ b/backend/internal/proxytransport/address_test.go @@ -0,0 +1,179 @@ +package proxytransport + +import ( + "encoding/json" + "errors" + "net/url" + "strings" + "testing" +) + +func TestProxyCandidateFormatsAndCredentialRules(t *testing.T) { + for _, tc := range []struct { + name, raw, hint, protocol, host, user, password string + port int + }{ + {"uri http", "http://u:p@Proxy.Example:8080", "socks5", "http", "Proxy.Example", "u", "p", 8080}, + {"uri https default", "https://Proxy.Example", "http", "https", "Proxy.Example", "", "", 443}, + {"uri socks", "socks5://u:p@proxy.example:1080", "https", "socks5", "proxy.example", "u", "p", 1080}, + {"uri socks remote dns", "socks5h://u:p@proxy.example:1080", "http", "socks5h", "proxy.example", "u", "p", 1080}, + {"authority", "proxy.example:3128", "socks5h", "socks5h", "proxy.example", "", "", 3128}, + {"auth first", "user:p%2540ss@proxy.example:8080", "", "http", "proxy.example", "user", "p%40ss", 8080}, + {"auth last", "proxy.example:8080@user:p@ss:#", "", "http", "proxy.example", "user", "p@ss:#", 8080}, + {"four host first", "proxy.example:8080:user:p:a%40 ss", "", "http", "proxy.example", "user", "p:a%40 ss", 8080}, + {"four auth first", "user:p%40ss:proxy.example:8080", "", "http", "proxy.example", "user", "p%40ss", 8080}, + {"space columns", "proxy.example 8080 user ' p a '", "", "http", "proxy.example", "user", " p a ", 8080}, + {"tab columns", "user\tp%40ss\tproxy.example\t8080", "", "http", "proxy.example", "user", "p%40ss", 8080}, + {"comma columns", "proxy.example,8080,\" user \",\"p,a|b;c\"", "", "http", "proxy.example", " user ", "p,a|b;c", 8080}, + {"pipe columns", "user|\" p|a \"|proxy.example|8080", "", "http", "proxy.example", "user", " p|a ", 8080}, + {"semicolon columns", "proxy.example;8080;user;\"p;a\"", "", "http", "proxy.example", "user", "p;a", 8080}, + {"ipv6", "[2001:db8::1]:8080", "", "http", "2001:db8::1", "", "", 8080}, + {"ipv6 four", "[2001:db8::1]:8080:user:p", "", "http", "2001:db8::1", "user", "p", 8080}, + {"ipv6 auth first", "user:p:[2001:db8::1]:8080", "", "http", "2001:db8::1", "user", "p", 8080}, + {"english labels", "password: p@ss:#\nPort: 2721\nProxy Server: proxy.example\nusername: u+s", "", "http", "proxy.example", "u+s", "p@ss:#", 2721}, + {"chinese labels", "协议:socks5h 主机:proxy.example 端口:8080 用户名:user 密码:\" p,;: 空格 \"", "https", "socks5h", "proxy.example", "user", " p,;: 空格 ", 8080}, + {"label continuation", "host=proxy.example port=8080\nusername=user\npassword=secret", "", "http", "proxy.example", "user", "secret", 8080}, + {"IP labels CR", "IP: 127.0.0.1\r端口: 8080\r用户名: user\r密码: p", "", "http", "127.0.0.1", "user", "p", 8080}, + {"address labels", "代理地址: [::1]\n端口: 8080\n用户名: user\n密码: p;", "", "http", "::1", "user", "p;", 8080}, + {"URI label protocol", "host: https://user:p@Proxy.Example:8080\nprotocol: https", "socks5", "https", "Proxy.Example", "user", "p", 8080}, + } { + t.Run(tc.name, func(t *testing.T) { + parsed := Parse(tc.raw, tc.hint) + if len(parsed.Candidates) != 1 || len(parsed.Issues) != 0 || parsed.SelectionRequired || parsed.SelectionID == "" { + t.Fatalf("expected one clean candidate, got %d and issues %+v", len(parsed.Candidates), parsed.Issues) + } + normal, err := Resolve(tc.raw, tc.hint, parsed.SelectionID) + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(normal) + if err != nil { + t.Fatal("invalid normalized URL") + } + user, password := "", "" + if u.User != nil { + user = u.User.Username() + password, _ = u.User.Password() + } + if u.Scheme != tc.protocol || u.Hostname() != tc.host || user != tc.user || password != tc.password || effectivePort(u) != tc.port { + t.Fatal("selected endpoint changed protocol, host, port or credential bytes") + } + }) + } +} + +func TestProxyCandidatesAmbiguityDedupeSelectionAndRedaction(t *testing.T) { + raw := "proxy.example:8080:user:1234" + parsed := Parse(raw, "http") + if len(parsed.Candidates) != 2 || !parsed.SelectionRequired || parsed.SelectionID != "" { + t.Fatal("both four-field orders must remain candidates") + } + _, err := Resolve(raw, "http", "") + var selection *SelectionError + if !errors.As(err, &selection) || selection.Code != "proxy_selection_required" { + t.Fatal("ambiguous input did not require selection") + } + for _, item := range parsed.Candidates { + if _, err := Resolve(raw, "http", item.SelectionID); err != nil { + t.Fatal(err) + } + for _, changed := range []struct{ raw, hint string }{{raw + " ", "http"}, {raw, "https"}, {"single.example:80", "http"}} { + _, err := Resolve(changed.raw, changed.hint, item.SelectionID) + if !errors.As(err, &selection) || selection.Code != "proxy_selection_stale" { + t.Fatal("changed draft accepted old selection") + } + } + } + raw = "http://fixture-user:fixture-password@Proxy.Example:80\nhttp://fixture-user:fixture-password@proxy.example\nhttp://fixture-user:different-password@proxy.example:80" + parsed = Parse(raw, "socks5") + if len(parsed.Candidates) != 2 { + t.Fatal("dedupe must include complete credentials and effective endpoint") + } + encoded, _ := json.Marshal(parsed) + for _, secret := range []string{"fixture-user", "fixture-password", "different-password", "http://"} { + if strings.Contains(string(encoded), secret) { + t.Fatal("candidate serialization leaked credential material") + } + } + preserved, err := Resolve(raw, "socks5", parsed.Candidates[0].SelectionID) + if err != nil || preserved != strings.Split(raw, "\n")[0] { + t.Fatal("existing explicit URI representation changed") + } +} + +func TestProxyCandidatesKeepValidRecordsAndRejectConflictingFields(t *testing.T) { + raw := "http://u:first@one.example:80; http://u:second@one.example:80\r\nbad.example:99999\r[::1]:8080" + parsed := Parse(raw, "http") + if len(parsed.Candidates) != 3 || len(parsed.Issues) != 1 || parsed.Issues[0].Line != 2 || parsed.Candidates[2].SourceLine != 3 || parsed.Candidates[1].SourceColumn <= 1 { + t.Fatalf("multi-record provenance or validation incorrect: %d candidates, issues %+v", len(parsed.Candidates), parsed.Issues) + } + for _, item := range parsed.Candidates { + if _, err := Resolve(raw, "http", item.SelectionID); err != nil { + t.Fatal(err) + } + } + for _, raw := range []string{ + "host: proxy.example\nport: 8080\nport: 9090", + "host: proxy.example\nport: 8080\nusername: user", + "host: https://proxy.example:8080\nprotocol: http", + "host: proxy.example:8080\nport: 9090", + "host: http://user:secret@proxy.example:80\nusername: other\npassword: secret", + "2001:db8::1:1080", + "ftp://user:secret@proxy.example:21", + "proxy.example,8080,user,\"unclosed", + } { + parsed := Parse(raw, "http") + if len(parsed.Candidates) != 0 || len(parsed.Issues) == 0 { + t.Fatal("invalid or contradictory fields produced a selectable proxy") + } + } +} + +func TestProxyEndpointTransportIsStrictAndPreservesRepresentation(t *testing.T) { + for _, raw := range []string{"host:8080", "host:8080:user:password", "user:password@host:8080", "http://host:8080\nhttp://other:8080", "http://host:99999", "http://2001:db8::1:8080"} { + if _, err := ParseEndpoint(raw); err == nil { + t.Fatal("transport accepted a draft instead of a validated endpoint") + } + } + for _, raw := range []string{"http://U:p%2540@Proxy.Example:80", "https://Proxy.Example", "socks5://u:p@[::1]:1080"} { + normal, err := Normalize(raw) + if err != nil || normal != raw { + t.Fatal("valid saved representation or protocol changed") + } + } +} + +func TestProxyCredentialsAreNotFormatDiscriminators(t *testing.T) { + for _, tc := range []struct{ raw, user, password string }{ + {"proxy.example:8080:user:http://literal", "user", "http://literal"}, + {"user:http://literal:proxy.example:8080", "user", "http://literal"}, + {`" user:literal ":" p@ss ,;| "@[::1]:8080`, " user:literal ", " p@ss ,;| "}, + {`user:"%40"@proxy.example:8080`, "user", "%40"}, + } { + parsed := Parse(tc.raw, "http") + if len(parsed.Candidates) != 1 || len(parsed.Issues) > 0 { + t.Fatalf("credential text changed format detection: %d candidates, %+v", len(parsed.Candidates), parsed.Issues) + } + normal, err := Resolve(tc.raw, "http", parsed.SelectionID) + if err != nil { + t.Fatal(err) + } + u, _ := url.Parse(normal) + password, _ := u.User.Password() + if u.User.Username() != tc.user || password != tc.password { + t.Fatal("quoted credential bytes changed") + } + } + if got := Parse("http://host:80:user:1234", "http"); len(got.Candidates) != 0 { + t.Fatal("malformed explicit URI fell back to a different field order") + } + parsed := Parse("http://[::1]:80\nhttp://[0:0:0:0:0:0:0:1]", "") + if len(parsed.Candidates) != 1 { + t.Fatal("equivalent IPv6 endpoints were not deduplicated") + } + _, err := Resolve("http://[::1]:80\nhttp://[0:0:0:0:0:0:0:1]", "http", parsed.SelectionID) + var invalid *SelectionError + if !errors.As(err, &invalid) || invalid.Code != "proxy_selection_stale" { + t.Fatal("changed protocol input did not invalidate selection") + } +} diff --git a/backend/internal/repository/account_job_connection_integration_test.go b/backend/internal/repository/account_job_connection_integration_test.go new file mode 100644 index 000000000000..da8aad3dddc9 --- /dev/null +++ b/backend/internal/repository/account_job_connection_integration_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package repository + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestAccountJobConnectionSnapshotAndStoppedRetry(t *testing.T) { + ctx := context.Background() + user := mustCreateUser(t, testEntClient(t), &service.User{Email: "job-connection-" + uuid.NewString() + "@example.com", PasswordHash: "fixture"}) + repo := &accountJobRepository{db: integrationDB} + var ids []int64 + t.Cleanup(func() { + for _, id := range ids { + _, _ = integrationDB.ExecContext(ctx, "DELETE FROM admin_account_jobs WHERE id=$1", id) + } + _, _ = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id=$1", user.ID) + }) + create := func(count int) *service.AccountJob { + seeds := make([]service.AccountJobItemSeed, count) + for i := range seeds { + seeds[i].Ordinal = i + 1 + } + job, _, err := repo.Create(ctx, service.CreateAccountJobParams{CreatedBy: user.ID, Kind: service.AccountJobKindBatchTest, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("a", 64), PayloadCipher: "encrypted-fixture", PayloadExpires: time.Now().Add(time.Hour), Items: seeds, Attempt: 1}) + require.NoError(t, err) + ids = append(ids, job.ID) + _, err = integrationDB.ExecContext(ctx, "UPDATE admin_account_jobs SET status='running' WHERE id=$1", job.ID) + require.NoError(t, err) + return job + } + job := create(3) + items, err := repo.ReservePendingItems(ctx, job.ID, 100) + require.NoError(t, err) + require.Len(t, items, 3) + plan := json.RawMessage(`{"execution_plan":{"model_id":"frozen","mapped_model_id":"wire","reasoning_effort":"high","plan_stamp":"stamp"},"model_id":"wire"}`) + require.NoError(t, repo.SaveExecutionSnapshot(ctx, job.ID, items[0].ID, plan)) + require.NoError(t, repo.SaveExecutionSnapshot(ctx, job.ID, items[0].ID, plan), "the same plan is idempotent") + require.Error(t, repo.SaveExecutionSnapshot(ctx, job.ID, items[0].ID, json.RawMessage(`{"execution_plan":{"model_id":"changed"}}`))) + require.NoError(t, repo.CompleteItems(ctx, job.ID, []service.AccountJobExecutionResult{ + {ItemID: items[0].ID, Status: service.AccountJobItemStatusFailed, ErrorCode: "test_timeout", Metadata: json.RawMessage(`{"latency_ms":90}`)}, + {ItemID: items[1].ID, Status: service.AccountJobItemStatusSucceeded, Metadata: json.RawMessage(`{"recovery_status":"warning"}`)}, + })) + _, err = repo.Cancel(ctx, job.ID, user.ID) + require.NoError(t, err) + finished, err := repo.Finish(ctx, job.ID, "", "") + require.NoError(t, err) + require.Equal(t, service.AccountJobStatusCanceled, finished.Status) + _, seeds, _, _, err := repo.FailedItemSeeds(ctx, job.ID, user.ID) + require.NoError(t, err) + require.Len(t, seeds, 1) + require.Contains(t, string(seeds[0].Metadata), `"model_id": "frozen"`) + jobs := service.NewAccountJobService(repo, nil) + view, err := jobs.Get(ctx, job.ID) + require.NoError(t, err) + require.True(t, view.RetryEligible) + _, err = integrationDB.ExecContext(ctx, "UPDATE admin_account_jobs SET raw_payload_expires_at=NOW()-INTERVAL '1 second' WHERE id=$1", job.ID) + require.NoError(t, err) + view, err = jobs.Get(ctx, job.ID) + require.NoError(t, err) + require.False(t, view.RetryEligible) + require.Equal(t, "payload_expired", view.RetryUnavailableReason) + // All model calls may have succeeded before the monitor failed. The task + // remains failed, and retry has no model calls to replay. + job = create(1) + items, err = repo.ReservePendingItems(ctx, job.ID, 100) + require.NoError(t, err) + require.NoError(t, repo.CompleteItems(ctx, job.ID, []service.AccountJobExecutionResult{{ItemID: items[0].ID, Status: service.AccountJobItemStatusSucceeded}})) + finished, err = repo.Finish(ctx, job.ID, "cancel_check_failed", "account job cancellation state is unavailable") + require.NoError(t, err) + require.Equal(t, service.AccountJobStatusFailed, finished.Status) + view, err = jobs.Get(ctx, job.ID) + require.NoError(t, err) + require.False(t, view.RetryEligible) +} diff --git a/backend/internal/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go index bbe0a0798879..fcc0e0c8a92f 100644 --- a/backend/internal/repository/account_job_repo.go +++ b/backend/internal/repository/account_job_repo.go @@ -425,6 +425,27 @@ func (r *accountJobRepository) CancelRequested(ctx context.Context, jobID int64) return canceled, err } +func (r *accountJobRepository) SaveExecutionSnapshot(ctx context.Context, jobID, itemID int64, metadata json.RawMessage) error { + if err := service.ValidateAccountJobMetadata(metadata); err != nil { + return err + } + result, err := r.db.ExecContext(ctx, `UPDATE admin_account_job_items + SET metadata=metadata || $3::jsonb, updated_at=NOW() + WHERE job_id=$1 AND id=$2 AND status='running' + AND (NOT (metadata ? 'execution_plan') OR metadata->'execution_plan'=$3::jsonb->'execution_plan')`, jobID, itemID, string(metadata)) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return errors.New("account test execution plan changed") + } + return nil +} + func (r *accountJobRepository) CompleteItems(ctx context.Context, jobID int64, results []service.AccountJobExecutionResult) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -439,7 +460,7 @@ func (r *accountJobRepository) CompleteItems(ctx context.Context, jobID int64, r } metadata := normalizeRepositoryJobMetadata(result.Metadata) updated, updateErr := tx.ExecContext(ctx, `UPDATE admin_account_job_items - SET status=$3, metadata=$4::jsonb, error_code=NULLIF($5,''), error_message=NULLIF($6,''), + SET status=$3, metadata=metadata || $4::jsonb, error_code=NULLIF($5,''), error_message=NULLIF($6,''), finished_at=NOW(), updated_at=NOW() WHERE job_id=$1 AND id=$2 AND status='running'`, jobID, result.ItemID, status, string(metadata), result.ErrorCode, result.ErrorMessage) @@ -514,21 +535,11 @@ func (r *accountJobRepository) Finish(ctx context.Context, jobID int64, errorCod if err = refreshAccountJobCounts(ctx, tx, jobID); err != nil { return nil, err } - status := service.AccountJobStatusFailed - if cancelRequested { - status = service.AccountJobStatusCanceled - } else { - var succeeded, failed int - if err = tx.QueryRowContext(ctx, `SELECT succeeded_count, failed_count FROM admin_account_jobs WHERE id=$1`, jobID).Scan(&succeeded, &failed); err != nil { - return nil, err - } - switch { - case failed == 0: - status = service.AccountJobStatusSucceeded - case succeeded > 0: - status = service.AccountJobStatusPartiallySucceeded - } + var succeeded, failed, canceled int + if err = tx.QueryRowContext(ctx, `SELECT succeeded_count, failed_count, canceled_count FROM admin_account_jobs WHERE id=$1`, jobID).Scan(&succeeded, &failed, &canceled); err != nil { + return nil, err } + status := service.AccountJobTerminalStatus(cancelRequested, errorCode, succeeded, failed, canceled) job, err := scanAccountJob(tx.QueryRowContext(ctx, `UPDATE admin_account_jobs SET status=$2, error_code=NULLIF($3,''), error_message=NULLIF($4,''), finished_at=NOW(), updated_at=NOW() WHERE id=$1 RETURNING `+accountJobSelectColumns, jobID, status, errorCode, errorMessage)) @@ -594,7 +605,7 @@ func (r *accountJobRepository) FailedItemSeeds(ctx context.Context, jobID, creat if err != nil || job.CreatedBy != createdBy { return nil, nil, "", time.Time{}, service.ErrAccountJobNotFound } - if job.Status != service.AccountJobStatusFailed && job.Status != service.AccountJobStatusPartiallySucceeded { + if !service.AccountJobHasRetryableFailures(job) { return nil, nil, "", time.Time{}, service.ErrAccountJobNotRetryable } payload, expires, err := r.Payload(ctx, jobID) diff --git a/backend/internal/repository/account_test_recovery.go b/backend/internal/repository/account_test_recovery.go new file mode 100644 index 000000000000..abec804bdd0d --- /dev/null +++ b/backend/internal/repository/account_test_recovery.go @@ -0,0 +1,58 @@ +package repository + +import ( + "context" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// Lock and inspect the current account in the same statement as the recovery. +// schedulable is never assigned; disabled/expired accounts are left intact. +func (r *accountRepository) RecoverAfterSuccessfulTest(ctx context.Context, id int64) (*service.SuccessfulTestRecoveryResult, error) { + result := &service.SuccessfulTestRecoveryResult{} + rows, err := r.sql.QueryContext(ctx, `WITH current AS MATERIALIZED ( + SELECT id, status, schedulable, status='error' AS had_error, + (rate_limited_at IS NOT NULL OR rate_limit_reset_at IS NOT NULL OR overload_until IS NOT NULL + OR temp_unschedulable_until IS NOT NULL + OR COALESCE(extra->'model_rate_limits','{}'::jsonb) NOT IN ('{}'::jsonb,'null'::jsonb) + OR COALESCE(extra->'antigravity_quota_scopes','{}'::jsonb) NOT IN ('{}'::jsonb,'null'::jsonb)) AS had_limits + FROM accounts WHERE id=$1 AND deleted_at IS NULL FOR UPDATE + ), updated AS ( + UPDATE accounts a SET status=CASE WHEN current.had_error THEN 'active' ELSE a.status END, + error_message=CASE WHEN current.had_error THEN '' ELSE a.error_message END, + rate_limited_at=NULL, rate_limit_reset_at=NULL, overload_until=NULL, + temp_unschedulable_until=NULL, temp_unschedulable_reason=NULL, + extra=COALESCE(a.extra,'{}'::jsonb)-'model_rate_limits'-'antigravity_quota_scopes', updated_at=NOW() + FROM current WHERE a.id=current.id AND current.status IN ('active','error') + AND (current.had_error OR current.had_limits) RETURNING a.id + ), emitted AS ( + INSERT INTO scheduler_outbox (event_type,account_id,group_id,payload) + SELECT $2,id,NULL,NULL FROM updated + ) + SELECT current.had_error AND EXISTS(SELECT 1 FROM updated), + current.had_limits AND EXISTS(SELECT 1 FROM updated), + current.status NOT IN ('active','error') OR NOT current.schedulable FROM current`, + id, service.SchedulerOutboxEventAccountChanged) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, err + } + return nil, service.ErrAccountNotFound + } + if err := rows.Scan(&result.ClearedError, &result.ClearedRateLimit, &result.ManualStatePreserved); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + if result.ClearedError || result.ClearedRateLimit { + r.syncSchedulerAccountSnapshotDetached(ctx, id) + } + return result, nil +} + +var _ service.SuccessfulTestRecoveryRepository = (*accountRepository)(nil) diff --git a/backend/internal/repository/account_test_recovery_integration_test.go b/backend/internal/repository/account_test_recovery_integration_test.go new file mode 100644 index 000000000000..930fe2f99bee --- /dev/null +++ b/backend/internal/repository/account_test_recovery_integration_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestAccountRepositorySuccessfulTestRecoveryAtomic(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + a := mustCreateAccount(t, tx.Client(), &service.Account{Name: "connection recovery", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture"}}) + _, err := tx.ExecContext(ctx, `UPDATE accounts SET status='error',error_message='recoverable',schedulable=false,rate_limited_at=NOW(),rate_limit_reset_at=NOW()+INTERVAL '1 hour',overload_until=NOW()+INTERVAL '1 hour',temp_unschedulable_until=NOW()+INTERVAL '1 hour',temp_unschedulable_reason='transient',extra='{"keep":true,"model_rate_limits":{"m":1},"antigravity_quota_scopes":{"g":1}}'::jsonb WHERE id=$1`, a.ID) + require.NoError(t, err) + result, err := repo.RecoverAfterSuccessfulTest(ctx, a.ID) + require.NoError(t, err) + require.True(t, result.ClearedError) + require.True(t, result.ClearedRateLimit) + require.True(t, result.ManualStatePreserved) + after, err := repo.GetByID(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, service.StatusActive, after.Status) + require.False(t, after.Schedulable) + require.Nil(t, after.RateLimitResetAt) + require.Nil(t, after.TempUnschedulableUntil) + require.Nil(t, after.OverloadUntil) + require.Equal(t, true, after.Extra["keep"]) + require.NotContains(t, after.Extra, "model_rate_limits") + result, err = repo.RecoverAfterSuccessfulTest(ctx, a.ID) + require.NoError(t, err) + require.False(t, result.ClearedError) + require.False(t, result.ClearedRateLimit) + // A test began with an error snapshot; the administrator disabled it before + // that test completed. Recovery must inspect the current locked row. + _, err = tx.ExecContext(ctx, `UPDATE accounts SET status='error',error_message='old',schedulable=true WHERE id=$1`, a.ID) + require.NoError(t, err) + observed, err := repo.GetByID(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, service.StatusError, observed.Status) + _, err = tx.ExecContext(ctx, `UPDATE accounts SET status='disabled',schedulable=false,error_message='manual',rate_limit_reset_at=$2 WHERE id=$1`, a.ID, time.Now().Add(time.Hour)) + require.NoError(t, err) + result, err = repo.RecoverAfterSuccessfulTest(ctx, a.ID) + require.NoError(t, err) + require.True(t, result.ManualStatePreserved) + require.False(t, result.ClearedError) + require.False(t, result.ClearedRateLimit) + after, err = repo.GetByID(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, service.StatusDisabled, after.Status) + require.Equal(t, "manual", after.ErrorMessage) + require.False(t, after.Schedulable) + require.NotNil(t, after.RateLimitResetAt) +} diff --git a/backend/internal/repository/business_system_prompt_config.go b/backend/internal/repository/business_system_prompt_config.go index d1970af953dd..f2c3bd5c50e7 100644 --- a/backend/internal/repository/business_system_prompt_config.go +++ b/backend/internal/repository/business_system_prompt_config.go @@ -84,9 +84,13 @@ func (r *businessSystemPromptRepository) SavePromptConfig(ctx context.Context, i for i := range policy.Rules { rule := &policy.Rules[i] draft, hasDraft := input.Contents[rule.ID] - if old, exists := previousRules[rule.ID]; exists && old.TemplateID != rule.TemplateID { + old, exists := previousRules[rule.ID] + if exists && old.TemplateID != rule.TemplateID { return fmt.Errorf("%w: an existing rule cannot change its content source", service.ErrBusinessSystemPromptInvalid) } + if !exists && (rule.TemplateID != 0 || rule.VersionID != 0 || !hasDraft) { + return fmt.Errorf("%w: a new rule requires independent content", service.ErrBusinessSystemPromptInvalid) + } var version promptConfigStoredVersion if rule.TemplateID == 0 && rule.VersionID == 0 && hasDraft { if err := tx.QueryRowContext(ctx, `INSERT INTO system_prompt_templates (slug, name, created_by, updated_by) @@ -106,11 +110,22 @@ func (r *businessSystemPromptRepository) SavePromptConfig(ctx context.Context, i version.compositionMode, version.bundleID, version.bundleManifestSHA256); err != nil { return err } - if hasDraft && (version.managedSource != "" || version.compositionMode != service.BusinessSystemPromptCompositionInline) { + if version.managedSource != "" || version.compositionMode == service.BusinessSystemPromptCompositionCodexSkillHybrid { return fmt.Errorf("%w: managed prompt content requires an independent copy", service.ErrBusinessSystemPromptSourceNotManaged) } } if hasDraft { + preserveEcho := false + if draft.RestoreVersionID > 0 { + preserveEcho, err = validatePromptHistoryRestore(ctx, tx, rule.ID, rule.TemplateID, version.compositionMode, draft.RestoreVersionID, draft.Body) + if err != nil { + return err + } + } else if version.compositionMode == nativeapi.PromptContentAnthropicSystemBlocks { + if err := service.ValidateStructuredPromptTextEdit(version.body, draft.Body); err != nil { + return err + } + } version.body = draft.Body version.sha256, version.byteLength, err = service.ValidateBusinessSystemPromptBody(draft.Body) if err != nil { @@ -127,6 +142,11 @@ func (r *businessSystemPromptRepository) SavePromptConfig(ctx context.Context, i nullableString(version.bundleID), nullableString(version.bundleManifestSHA256), nullableActor(actorID)).Scan(&rule.VersionID); err != nil { return translateBusinessSystemPromptWriteError(err) } + if preserveEcho { + if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_version_compat (version_id, preserve_echo) VALUES ($1, TRUE)`, rule.VersionID); err != nil { + return err + } + } } if !hasDraft { if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_template_versions diff --git a/backend/internal/repository/business_system_prompt_rules_integration_test.go b/backend/internal/repository/business_system_prompt_rules_integration_test.go index 46eb322859e1..46e80d124e4d 100644 --- a/backend/internal/repository/business_system_prompt_rules_integration_test.go +++ b/backend/internal/repository/business_system_prompt_rules_integration_test.go @@ -288,7 +288,7 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM system_prompt_template_versions`).Scan(&versionCount)) badPolicy := policy badPolicy.Rules = append([]extensionv1.PromptRule{}, policy.Rules...) - badPolicy.Rules = append(badPolicy.Rules, promptConfigFixtureRule("unavailable", detail.Template.ID, 9223372036854775807)) + badPolicy.Rules[1].VersionID = 9223372036854775807 edit := service.PromptConfigUpdate{ ExpectedRevision: 43, Enabled: true, ExposeServerPrompt: true, CompactEnabled: true, Policy: badPolicy, Contents: map[string]service.PromptContentDraft{"legacy-default": {Body: "Atomic replacement content."}}, diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 84141e82d95f..17979124235a 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -32,6 +32,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/Wei-Shaw/sub2api/internal/proxytransport" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" ) @@ -555,7 +556,7 @@ func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID in // TLS 指纹客户端使用独立的缓存键,与普通客户端隔离 func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, upstreamProfile service.HTTPUpstreamProfile, markInFlight bool, enforceLimit bool) (*upstreamClientEntry, error) { isolation := s.getIsolationMode() - proxyKey, parsedProxy, err := normalizeProxyURL(proxyURL) + proxyKey, parsedProxy, err := proxyURLForProfile(proxyURL, upstreamProfile) if err != nil { return nil, err } @@ -716,7 +717,7 @@ func (s *httpUpstreamService) getClientEntry(proxyURL string, accountID int64, a // 获取隔离模式 isolation := s.getIsolationMode() // 标准化代理 URL 并解析 - proxyKey, parsedProxy, err := normalizeProxyURL(proxyURL) + proxyKey, parsedProxy, err := proxyURLForProfile(proxyURL, profile) if err != nil { return nil, err } @@ -1277,6 +1278,18 @@ func (s *openAIHTTP2FallbackState) recordFailure(now time.Time, threshold int, w // - string: 标准化的代理键(空返回 "direct") // - *url.URL: 解析后的 URL(空返回 nil) // - error: 非空代理 URL 解析失败时返回错误(禁止回退到直连) +// +// Acquisition already selected a validated endpoint. Keep that exact protocol +// and representation, including default ports and host casing used by trust +// keys; the general account-proxy compatibility normalizer is a different API. +func proxyURLForProfile(raw string, profile service.HTTPUpstreamProfile) (string, *url.URL, error) { + if profile == service.HTTPUpstreamProfileOpenAIHarvest { + parsed, err := proxytransport.ParseEndpoint(raw) + return raw, parsed, err + } + return normalizeProxyURL(raw) +} + func normalizeProxyURL(raw string) (string, *url.URL, error) { _, parsed, err := proxyurl.Parse(raw) if err != nil { diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index a1fc8ae22ce4..061b95640ab0 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -704,6 +704,36 @@ func (s *HTTPUpstreamSuite) TestOpenAIHarvestProfileDisablesKeepAlives() { require.Equal(s.T(), 0, transport.MaxIdleConns) } +func (s *HTTPUpstreamSuite) TestOpenAIHarvestProfilePreservesSelectedEndpoint() { + svc := s.newService() + for _, raw := range []string{"socks5://u:p@[::1]:1080", "http://u:p@Proxy.Example:80", "https://Proxy.Example"} { + key, parsed, err := proxyURLForProfile(raw, service.HTTPUpstreamProfileOpenAIHarvest) + require.NoError(s.T(), err) + require.Equal(s.T(), raw, key) + require.Equal(s.T(), strings.Split(raw, ":")[0], parsed.Scheme) + entry, err := svc.getClientEntry(raw, 41, 1, service.HTTPUpstreamProfileOpenAIHarvest, true, false) + require.NoError(s.T(), err) + require.Equal(s.T(), raw, entry.proxyKey) + require.EqualValues(s.T(), 1, atomic.LoadInt64(&entry.inFlight)) + transport, ok := entry.client.Transport.(*http.Transport) + require.True(s.T(), ok) + require.True(s.T(), transport.DisableKeepAlives, "acquisition still requires fresh connections") + atomic.AddInt64(&entry.inFlight, -1) + tlsEntry, err := svc.getClientEntryWithTLS(raw, 41, 1, &tlsfingerprint.Profile{Name: "test"}, service.HTTPUpstreamProfileOpenAIHarvest, false, false) + require.NoError(s.T(), err) + require.Equal(s.T(), raw, tlsEntry.proxyKey) + } + for _, draft := range []string{"proxy.example:8080", "proxy.example:8080:user:password", ""} { + _, err := svc.getClientEntry(draft, 41, 1, service.HTTPUpstreamProfileOpenAIHarvest, false, false) + require.Error(s.T(), err, "acquisition cannot reinterpret drafts or silently use direct egress") + _, err = svc.getClientEntryWithTLS(draft, 41, 1, &tlsfingerprint.Profile{Name: "test"}, service.HTTPUpstreamProfileOpenAIHarvest, false, false) + require.Error(s.T(), err) + } + _, parsed, err := proxyURLForProfile("socks5://proxy.example:1080", service.HTTPUpstreamProfileDefault) + require.NoError(s.T(), err) + require.Equal(s.T(), "socks5h", parsed.Scheme, "general account proxy behavior remains outside this change") +} + func (s *HTTPUpstreamSuite) TestOpenAIHeaderTimeoutChangeRebuildsClient() { s.cfg.Gateway = config.GatewayConfig{ OpenAIHTTP2: config.GatewayOpenAIHTTP2Config{Enabled: true}, diff --git a/backend/internal/repository/prompt_independent.go b/backend/internal/repository/prompt_independent.go new file mode 100644 index 000000000000..d91b44467b5d --- /dev/null +++ b/backend/internal/repository/prompt_independent.go @@ -0,0 +1,239 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "slices" + + "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/google/uuid" +) + +// The runtime row serializes this one-time upgrade with config/account writes. +// All source rows are retained. Reading disk happens before any write, while +// the paired registry runtime is locked, so neither half can change generation. +func (r *businessSystemPromptRepository) InitializeIndependentPrompts(ctx context.Context, files service.RemoteSkillRegistryFiles) (map[string][]byte, error) { + if r == nil || r.db == nil { + return nil, service.ErrBusinessSystemPromptUnavailable + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + var revision int64 + if err := tx.QueryRowContext(ctx, `SELECT revision FROM system_prompt_runtime WHERE id = 1 FOR UPDATE`).Scan(&revision); err != nil { + return nil, err + } + var complete bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM system_prompt_independent_state WHERE id = 1)`).Scan(&complete); err != nil { + return nil, err + } + if complete { + frozen, err := readFrozenPromptFiles(ctx, tx) + if err != nil { + return nil, err + } + return frozen, tx.Commit() + } + var raw []byte + if err := tx.QueryRowContext(ctx, `SELECT policy FROM system_prompt_rule_policies WHERE id = 1 FOR UPDATE`).Scan(&raw); err != nil { + return nil, err + } + var policy nativeapi.PromptRulePolicy + if err := json.Unmarshal(raw, &policy); err != nil || policy.Version != nativeapi.PromptRulePolicyVersion { + return nil, service.ErrBusinessSystemPromptUnavailable + } + var registryRevision int64 + if err := tx.QueryRowContext(ctx, `SELECT revision FROM system_prompt_skill_runtime WHERE id = 1 FOR UPDATE`).Scan(®istryRevision); err != nil { + return nil, err + } + registry, err := loadRemoteSkillRegistrySnapshot(ctx, tx) + if err != nil { + return nil, err + } + frozen := make(map[string][]byte) + var publication service.RemoteSkillPublication + if registry.Active != nil { + if registry.ActivePrompt == nil || files == nil { + return nil, service.ErrBusinessSystemPromptBundleUnavailable + } + detail, err := getRemoteSkillVersionDetail(ctx, tx, registry.Active.ID, 0) + if err != nil { + return nil, err + } + if detail.Prompt.ID != registry.ActivePrompt.ID { + return nil, service.ErrBusinessSystemPromptBundleInvalid + } + candidate, err := files.LoadCandidate(ctx, detail.RemoteSkillBundleVersion, detail.Prompt, detail.FileChanges) + if err != nil { + return nil, fmt.Errorf("%w: active skill files incomplete", service.ErrBusinessSystemPromptBundleUnavailable) + } + publication, err = service.ValidateFrozenSkillPublication(registryRevision, candidate) + if err != nil { + return nil, err + } + frozen = publication.Files + } + // Prepare every rule first. A missing disabled/custom-bound source is also + // fatal: migration cannot silently replace its future behavior with a seed. + type independentContent struct { + ruleIndex int + body, mode string + oldTemplate int64 + echo bool + } + contents := make([]independentContent, 0, len(policy.Rules)) + for i, rule := range policy.Rules { + version, err := loadPromptConfigVersion(ctx, tx, rule.TemplateID, rule.VersionID) + if err != nil { + return nil, err + } + if err := validateStoredBusinessSystemPromptVersion(version.body, version.sha256, version.byteLength, version.compositionMode, version.bundleID, version.bundleManifestSHA256); err != nil { + return nil, err + } + content := independentContent{ruleIndex: i, body: version.body, mode: version.compositionMode, oldTemplate: rule.TemplateID} + if version.compositionMode == service.BusinessSystemPromptCompositionCodexSkillHybrid { + if publication.EffectivePromptBody == "" || len(frozen) == 0 { + return nil, service.ErrBusinessSystemPromptBundleUnavailable + } + content.body, content.mode, content.echo = publication.EffectivePromptBody, service.BusinessSystemPromptCompositionInline, true + } + if _, _, err := service.ValidateBusinessSystemPromptBody(content.body); err != nil { + return nil, err + } + if content.mode == nativeapi.PromptContentAnthropicSystemBlocks { + if err := service.ValidateStructuredPromptTextEdit(content.body, content.body); err != nil { + return nil, err + } + } + contents = append(contents, content) + } + for _, content := range contents { + rule := &policy.Rules[content.ruleIndex] + if err := tx.QueryRowContext(ctx, `INSERT INTO system_prompt_templates (slug, name) VALUES ($1, $2) RETURNING id`, "prompt-"+uuid.NewString(), rule.Name).Scan(&rule.TemplateID); err != nil { + return nil, err + } + hash, length, _ := service.ValidateBusinessSystemPromptBody(content.body) + if err := tx.QueryRowContext(ctx, `INSERT INTO system_prompt_template_versions + (template_id, version, body, sha256, byte_length, composition_mode, note, published_at) + VALUES ($1, 1, $2, $3, $4, $5, 'Independent content migration', NOW()) RETURNING id`, rule.TemplateID, content.body, hash, length, content.mode).Scan(&rule.VersionID); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_independent_contents (template_id, rule_id, source_template_id, baseline_version_id) VALUES ($1, $2, $3, $4)`, rule.TemplateID, rule.ID, content.oldTemplate, rule.VersionID); err != nil { + return nil, err + } + if content.echo { + if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_version_compat (version_id, preserve_echo) VALUES ($1, TRUE)`, rule.VersionID); err != nil { + return nil, err + } + } + } + paths := make([]string, 0, len(frozen)) + for path := range frozen { + paths = append(paths, path) + } + slices.Sort(paths) + for _, path := range paths { + body := frozen[path] + if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_frozen_public_files (path, body, sha256) VALUES ($1, $2, encode(sha256($2::bytea), 'hex'))`, path, body); err != nil { + return nil, err + } + } + raw, err = json.Marshal(policy) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_rule_policies SET policy = $1::jsonb WHERE id = 1`, string(raw)); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_runtime SET revision = revision + 1, updated_at = NOW() WHERE id = 1`); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_independent_state (id) VALUES (1)`); err != nil { + return nil, err + } + return frozen, tx.Commit() +} + +func readFrozenPromptFiles(ctx context.Context, tx *sql.Tx) (map[string][]byte, error) { + rows, err := tx.QueryContext(ctx, `SELECT path, body, sha256 = encode(sha256(body), 'hex') FROM system_prompt_frozen_public_files ORDER BY path`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + files := make(map[string][]byte) + for rows.Next() { + var path string + var body []byte + var valid bool + if err := rows.Scan(&path, &body, &valid); err != nil { + return nil, err + } + if !valid { + return nil, service.ErrBusinessSystemPromptBundleInvalid + } + files[path] = body + } + return files, rows.Err() +} + +func (r *businessSystemPromptRepository) PromptVersionPreserveEcho(ctx context.Context, versionID int64) (bool, error) { + var preserve bool + err := r.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM system_prompt_version_compat WHERE version_id = $1 AND preserve_echo)`, versionID).Scan(&preserve) + return preserve, err +} + +func (r *businessSystemPromptRepository) PromptRuleHistory(ctx context.Context, ruleID string) ([]service.PromptHistoryVersion, error) { + rows, err := r.db.QueryContext(ctx, `WITH current_rule AS ( + SELECT (rule->>'template_id')::bigint template_id, (rule->>'version_id')::bigint version_id + FROM system_prompt_rule_policies, jsonb_array_elements(policy->'rules') rule WHERE id = 1 AND rule->>'id' = $1 + ) SELECT v.id, v.body, v.composition_mode, v.created_at, + (v.composition_mode IN ('inline', 'anthropic_system_blocks') AND v.composition_mode = current_version.composition_mode) restorable + FROM current_rule r JOIN system_prompt_template_versions current_version ON current_version.id = r.version_id + LEFT JOIN system_prompt_independent_contents origin ON origin.template_id = r.template_id AND origin.rule_id = $1 + JOIN system_prompt_template_versions v ON v.template_id = r.template_id OR v.template_id = origin.source_template_id + ORDER BY v.created_at DESC, v.id DESC`, ruleID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + versions := make([]service.PromptHistoryVersion, 0) + for rows.Next() { + var version service.PromptHistoryVersion + if err := rows.Scan(&version.ID, &version.Body, &version.CompositionMode, &version.CreatedAt, &version.Restorable); err != nil { + return nil, err + } + versions = append(versions, version) + } + return versions, rows.Err() +} + +// Restoring is content-only and authorized by stable rule identity. Historical +// hybrid archives have no frozen-equivalent tree and cannot be restored. +func validatePromptHistoryRestore(ctx context.Context, tx *sql.Tx, ruleID string, templateID int64, mode string, versionID int64, body string) (bool, error) { + var historicalBody string + var historicalEcho bool + err := tx.QueryRowContext(ctx, `SELECT v.body, COALESCE(compat.preserve_echo, FALSE) FROM system_prompt_template_versions v + LEFT JOIN system_prompt_version_compat compat ON compat.version_id = v.id + WHERE v.id = $1 AND v.composition_mode = $2 + AND v.composition_mode IN ('inline', 'anthropic_system_blocks') + AND (v.template_id = $3 OR EXISTS ( + SELECT 1 FROM system_prompt_independent_contents origin + WHERE origin.rule_id = $4 AND origin.template_id = $3 AND origin.source_template_id = v.template_id))`, versionID, mode, templateID, ruleID).Scan(&historicalBody, &historicalEcho) + if err == sql.ErrNoRows { + return false, service.ErrBusinessSystemPromptVersionNotFound + } + if err != nil { + return false, err + } + if mode == nativeapi.PromptContentAnthropicSystemBlocks { + if err := service.ValidateStructuredPromptTextEdit(historicalBody, body); err != nil { + return false, err + } + } + return service.PreserveRestoredPromptEcho(historicalBody, body, historicalEcho), nil +} diff --git a/backend/internal/repository/prompt_independent_integration_test.go b/backend/internal/repository/prompt_independent_integration_test.go new file mode 100644 index 000000000000..84799dc028fe --- /dev/null +++ b/backend/internal/repository/prompt_independent_integration_test.go @@ -0,0 +1,239 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "maps" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/migrations" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/require" +) + +func independentPromptFixtureDB(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + var config *pgx.ConnConfig + connection, err := integrationDB.Conn(ctx) + require.NoError(t, err) + require.NoError(t, connection.Raw(func(driver any) error { + connection, ok := driver.(*stdlib.Conn) + if !ok { + return fmt.Errorf("expected PostgreSQL fixture connection") + } + config = connection.Conn().Config().Copy() + return nil + })) + require.NoError(t, connection.Close()) + schema := pgx.Identifier{fmt.Sprintf("prompt_core_%d", time.Now().UnixNano())}.Sanitize() + _, err = integrationDB.ExecContext(ctx, "CREATE SCHEMA "+schema) + require.NoError(t, err) + config.RuntimeParams = maps.Clone(config.RuntimeParams) + if config.RuntimeParams == nil { + config.RuntimeParams = map[string]string{} + } + config.RuntimeParams["search_path"] = schema + ", public" + db := stdlib.OpenDB(*config) + t.Cleanup(func() { + require.NoError(t, db.Close()) + _, err := integrationDB.ExecContext(context.Background(), "DROP SCHEMA "+schema+" CASCADE") + require.NoError(t, err) + }) + for _, table := range []string{"system_prompt_templates", "system_prompt_template_versions", "system_prompt_runtime", "system_prompt_rule_policies", "system_prompt_skill_runtime", "system_prompt_skill_bundle_versions", "system_prompt_skill_prompt_versions"} { + quoted := pgx.Identifier{table}.Sanitize() + _, err := db.ExecContext(ctx, "CREATE TABLE "+schema+"."+quoted+" (LIKE public."+quoted+" INCLUDING ALL)") + require.NoError(t, err) + } + _, err = db.ExecContext(ctx, `CREATE TABLE accounts (id BIGINT PRIMARY KEY, extra JSONB, deleted_at TIMESTAMPTZ); + CREATE TRIGGER fixture_prompt_immutable BEFORE UPDATE ON system_prompt_template_versions FOR EACH ROW EXECUTE FUNCTION public.protect_system_prompt_version_content(); + INSERT INTO system_prompt_runtime (id, enabled, expose_server_prompt, compact_enabled, revision) VALUES (1, TRUE, TRUE, TRUE, 7); + INSERT INTO system_prompt_skill_runtime (id, revision) VALUES (1, 1)`) + require.NoError(t, err) + raw, err := migrations.FS.ReadFile("256_prompt_independent_content.sql") + require.NoError(t, err) + _, err = db.ExecContext(ctx, string(raw)) + require.NoError(t, err) + return db +} + +type independentFixtureFiles struct { + service.RemoteSkillRegistryFiles + fail bool + loads int +} + +func (f *independentFixtureFiles) LoadCandidate(ctx context.Context, version service.RemoteSkillBundleVersion, prompt service.RemoteSkillPromptVersion, changes []service.RemoteSkillFileChange) (service.RemoteSkillCandidate, error) { + f.loads++ + if f.fail { + return service.RemoteSkillCandidate{}, errors.New("fixture missing current file") + } + return f.RemoteSkillRegistryFiles.LoadCandidate(ctx, version, prompt, changes) +} + +func TestPromptIndependentMigrationTransactionHistoryAndFrozenFilesIntegration(t *testing.T) { + ctx := context.Background() + db := independentPromptFixtureDB(t) + repo := &businessSystemPromptRepository{db: db} + files := &independentFixtureFiles{RemoteSkillRegistryFiles: service.NewRemoteSkillRegistryFilesystem(t.TempDir())} + // Local, embedded test fixture only. Production startup never calls either + // seed method: it reads the previously persisted active publication. + seed, err := files.LoadSeed(ctx) + require.NoError(t, err) + require.NoError(t, files.InstallCandidate(ctx, seed)) + registry := &remoteSkillRegistryRepository{db: db} + _, err = registry.EnsureRemoteSkillSeed(ctx, seed) + require.NoError(t, err) + structured := `{"blocks":[{"type":"text","text":"first","enabled":true,"cache_control":{"type":"ephemeral","ttl":"1h"}},{"type":"text","text":"disabled","enabled":false}],"expansion_prompt":"literal {fp}"}` + bodies := []string{" ordinary managed text\nexact bytes ", structured, "archived hybrid scaffold must never become active"} + modes := []string{"inline", nativeapi.PromptContentAnthropicSystemBlocks, service.BusinessSystemPromptCompositionCodexSkillHybrid} + policy := nativeapi.PromptRulePolicy{Version: 2, DefaultRuleIDs: []string{"plain", "structured", "hybrid"}} + oldVersions := make([]int64, 3) + for index, id := range policy.DefaultRuleIDs { + var template, version int64 + managed := any(nil) + if index == 0 { + managed = "github-seed" + } + if index == 2 { + managed = service.BusinessSystemPromptManagedSourceRemoteSkill + } + require.NoError(t, db.QueryRowContext(ctx, `INSERT INTO system_prompt_templates (slug, name, managed_source) VALUES ($1, $1, $2) RETURNING id`, id, managed).Scan(&template)) + hash, size, err := service.ValidateBusinessSystemPromptBody(bodies[index]) + require.NoError(t, err) + bundle := any(nil) + if index == 2 { + bundle = service.BusinessSystemPromptRemoteSkillBundleID + } + require.NoError(t, db.QueryRowContext(ctx, `INSERT INTO system_prompt_template_versions (template_id,version,body,sha256,byte_length,composition_mode,bundle_id) VALUES ($1,1,$2,$3,$4,$5,$6) RETURNING id`, template, bodies[index], hash, size, modes[index], bundle).Scan(&version)) + oldVersions[index] = version + rule := promptConfigFixtureRule(id, template, version) + rule.Order = (index + 1) * 100 + if index == 1 { + rule.Platforms = []string{"anthropic"} + rule.AccountTypes = []string{"oauth"} + rule.RequestProfiles = []string{"generic-mimic"} + rule.ExcludeModelContains = []string{"fable"} + } + policy.Rules = append(policy.Rules, rule) + } + raw, err := json.Marshal(policy) + require.NoError(t, err) + _, err = db.ExecContext(ctx, `INSERT INTO system_prompt_rule_policies (id,policy) VALUES (1,$1::jsonb);`, string(raw)) + require.NoError(t, err) + binding := `{"prompt_skills":{"mode":"custom","rule_ids":["hybrid","structured"]},"unrelated":"keep"}` + _, err = db.ExecContext(ctx, `INSERT INTO accounts (id,extra) VALUES (123,$1::jsonb)`, binding) + require.NoError(t, err) + files.fail = true + _, err = repo.InitializeIndependentPrompts(ctx, files) + require.ErrorIs(t, err, service.ErrBusinessSystemPromptBundleUnavailable) + var revision, templates, markers int + require.NoError(t, db.QueryRowContext(ctx, `SELECT revision FROM system_prompt_runtime WHERE id=1`).Scan(&revision)) + require.Equal(t, 7, revision) + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM system_prompt_templates`).Scan(&templates)) + require.Equal(t, 3, templates) + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM system_prompt_independent_state`).Scan(&markers)) + require.Zero(t, markers) + files.fail = false + frozen, err := repo.InitializeIndependentPrompts(ctx, files) + require.NoError(t, err) + require.Equal(t, seed.EffectiveFiles, frozen) + loaded, err := repo.LoadBusinessSystemPromptRules(ctx) + require.NoError(t, err) + require.Equal(t, int64(8), loaded.Revision) + require.True(t, loaded.Enabled && loaded.ExposeServerPrompt && loaded.CompactEnabled) + for index, rule := range loaded.RulePolicy.Rules { + original := policy.Rules[index] + require.NotEqual(t, original.TemplateID, rule.TemplateID) + require.NotEqual(t, original.VersionID, rule.VersionID) + original.TemplateID, original.VersionID = rule.TemplateID, rule.VersionID + require.Equal(t, original, rule, "identity, order and all scope must be unchanged") + } + require.Equal(t, policy.DefaultRuleIDs, loaded.RulePolicy.DefaultRuleIDs) + var preserved string + require.NoError(t, db.QueryRowContext(ctx, `SELECT extra::text FROM accounts WHERE id=123`).Scan(&preserved)) + require.JSONEq(t, binding, preserved) + again, err := repo.InitializeIndependentPrompts(ctx, nil) + require.NoError(t, err) + require.Equal(t, frozen, again) + require.Equal(t, 2, files.loads, "completed migration never consults the former registry/files again") + coreFiles := service.ProvideFrozenPromptFiles(repo, nil) + prompts := service.NewBusinessSystemPromptService(repo, nil) + prompts.SetFrozenPromptFiles(coreFiles) + require.NoError(t, prompts.Initialize(ctx)) + state, err := prompts.PromptConfig(ctx) + require.NoError(t, err) + require.Equal(t, bodies[0], state.Contents["plain"].Body) + require.Equal(t, structured, state.Contents["structured"].Body) + require.Equal(t, seed.Prompt.EffectiveBody, state.Contents["hybrid"].Body) + for _, content := range state.Contents { + require.False(t, content.Managed) + } + for _, path := range []string{"SKILL.md", "RULES.md", "README_AI.md"} { + file, err := coreFiles.LoadPublishedFile(ctx, path) + require.NoError(t, err) + require.Equal(t, seed.EffectiveFiles[path], file.Body) + } + history, err := prompts.PromptRuleHistory(ctx, "hybrid") + require.NoError(t, err) + require.Len(t, history, 2) + for _, version := range history { + require.Equal(t, version.ID != oldVersions[2], version.Restorable) + } + baseline := state.Contents["hybrid"].VersionID + echo, err := repo.PromptVersionPreserveEcho(ctx, baseline) + require.NoError(t, err) + require.True(t, echo) + // Omitted hidden switches and ordinary edit: exact baseline remains public, + // the new version becomes private without changing the global expose flag. + update := func(body string, restore int64) { + t.Helper() + raw, err := json.Marshal(map[string]any{"expected_revision": state.Revision, "enabled": state.Enabled, "policy": state.Policy, "contents": map[string]service.PromptContentDraft{"hybrid": {Body: body, RestoreVersionID: restore}}}) + require.NoError(t, err) + var input service.PromptConfigUpdate + require.NoError(t, json.Unmarshal(raw, &input)) + state, err = prompts.SavePromptConfig(ctx, input, 1) + require.NoError(t, err) + require.True(t, state.ExposeServerPrompt && state.CompactEnabled) + } + update("private edited body", 0) + echo, err = repo.PromptVersionPreserveEcho(ctx, state.Contents["hybrid"].VersionID) + require.NoError(t, err) + require.False(t, echo) + update(seed.Prompt.EffectiveBody, baseline) + echo, err = repo.PromptVersionPreserveEcho(ctx, state.Contents["hybrid"].VersionID) + require.NoError(t, err) + require.True(t, echo) + update("edited from public history", baseline) + echo, err = repo.PromptVersionPreserveEcho(ctx, state.Contents["hybrid"].VersionID) + require.NoError(t, err) + require.False(t, echo) + require.NoError(t, db.QueryRowContext(ctx, `SELECT body FROM system_prompt_template_versions WHERE id=$1`, oldVersions[2]).Scan(&preserved)) + require.Equal(t, bodies[2], preserved) + _, err = db.ExecContext(ctx, `UPDATE system_prompt_frozen_public_files SET body='changed' WHERE path='SKILL.md'`) + require.Error(t, err) + _, err = db.ExecContext(ctx, `UPDATE system_prompt_template_versions SET body='changed' WHERE id=$1`, baseline) + require.Error(t, err) +} + +func TestPromptIndependentFreshInstallHasNoSeedOrPublicationIntegration(t *testing.T) { + db := independentPromptFixtureDB(t) + _, err := db.Exec(`INSERT INTO system_prompt_rule_policies (id,policy) VALUES (1,'{"version":2,"rules":[],"default_rule_ids":[]}'::jsonb)`) + require.NoError(t, err) + repo := &businessSystemPromptRepository{db: db} + files, err := repo.InitializeIndependentPrompts(context.Background(), nil) + require.NoError(t, err) + require.Empty(t, files) + var templates int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM system_prompt_templates`).Scan(&templates)) + require.Zero(t, templates) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 32c6919bee66..56099c33fa28 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -114,8 +114,6 @@ var ProviderSet = wire.NewSet( NewContentModerationRepository, NewBusinessSystemPromptRepository, NewBusinessSystemPromptRevisionBus, - NewRemoteSkillRegistryRepository, - NewRemoteSkillRegistryRevisionBus, NewAffiliateRepository, NewUserPlatformQuotaRepository, // T14: user × platform quota NewUserPlatformQuotaServiceAdapter, // T14: adapter → service.UserPlatformQuotaRepository diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 19fa03062112..8218163e6f49 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1455,6 +1455,7 @@ func TestAPIContracts(t *testing.T) { "created_by": 1, "kind": "account_bulk_update", "status": "pending", + "retry_eligible": false, "metadata": {"target_count": 2}, "target_count": 2, "processed_count": 0, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index a42b33ed0d60..7e8e49c1c779 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -128,6 +128,7 @@ var auditSensitiveReads = map[string]string{ "GET /api/v1/admin/data-management/s3/config": "admin.data_management.s3_config.read", "GET /api/v1/admin/system-prompts": "admin.system_prompts.list", "GET /api/v1/admin/system-prompts/config": "admin.system_prompts.config.read", + "GET /api/v1/admin/system-prompts/rules/:rule_id/history": "admin.system_prompts.history.read", "GET /api/v1/admin/system-prompts/:id": "admin.system_prompts.read", "GET /api/v1/admin/system-prompts/:id/versions": "admin.system_prompts.versions.list", "GET /api/v1/admin/system-prompts/skill-registry": "admin.system_prompts.skill_registry.read", @@ -179,16 +180,19 @@ var auditActionOverrides = map[string]string{ // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 // 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。 var auditBodyOmittedRoutes = map[string]struct{}{ - "POST /api/v1/auth/passkey/login/finish": {}, - "POST /api/v1/user/passkeys/register/finish": {}, - "POST /api/v1/admin/accounts/import/codex-session": {}, - "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {}, - "PUT /api/v1/admin/prompt-audit/config": {}, - "POST /api/v1/admin/prompt-audit/endpoints/probe": {}, - "DELETE /api/v1/admin/prompt-audit/events/:id": {}, - "POST /api/v1/admin/prompt-audit/events/batch-delete": {}, - "POST /api/v1/admin/prompt-audit/events/delete-preview": {}, - "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {}, + "POST /api/v1/auth/passkey/login/finish": {}, + "POST /api/v1/user/passkeys/register/finish": {}, + "POST /api/v1/admin/accounts/import/codex-session": {}, + "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {}, + "PUT /api/v1/admin/prompt-audit/config": {}, + "POST /api/v1/admin/prompt-audit/endpoints/probe": {}, + "POST /api/v1/admin/settings/openai-codex-ticket/proxy-parse": {}, + "POST /api/v1/admin/settings/openai-codex-ticket/proxy-test": {}, + "PUT /api/v1/admin/settings/codex-runtime": {}, + "DELETE /api/v1/admin/prompt-audit/events/:id": {}, + "POST /api/v1/admin/prompt-audit/events/batch-delete": {}, + "POST /api/v1/admin/prompt-audit/events/delete-preview": {}, + "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {}, } // Prompt bodies and preview inputs are business-sensitive and may be much diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 1c8877852190..a85e0b6db6d6 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -342,6 +344,44 @@ func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *test require.Contains(t, auditBodyOmittedRoutes, route) } +func TestCodexProxyDraftRoutesOmitCredentialBearingAuditBodies(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, route := range []struct{ method, path string }{ + {http.MethodPost, "/api/v1/admin/settings/openai-codex-ticket/proxy-parse"}, + {http.MethodPost, "/api/v1/admin/settings/openai-codex-ticket/proxy-test"}, + {http.MethodPut, "/api/v1/admin/settings/codex-runtime"}, + } { + t.Run(route.method+" "+route.path, func(t *testing.T) { + repository := &auditCaptureRepository{} + auditService := service.NewAuditLogService(repository, nil) + auditService.Start() + router := gin.New() + router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService))) + body := `{"proxy_url":"proxy.test:8080:user:audit-canary-secret@other.test:9090","proxy_selection_id":"selected"}` + router.Handle(route.method, route.path, func(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + require.NoError(t, err) + require.Equal(t, body, string(raw)) + c.Status(http.StatusOK) + }) + request := httptest.NewRequest(route.method, route.path, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + auditService.Stop() + require.Equal(t, http.StatusOK, recorder.Code) + repository.mu.Lock() + logs := append([]*service.AuditLog(nil), repository.logs...) + repository.mu.Unlock() + require.Len(t, logs, 1) + require.Equal(t, "", logs[0].RequestBody) + encoded, err := json.Marshal(logs[0]) + require.NoError(t, err) + require.NotContains(t, string(encoded), "audit-canary") + }) + } +} + // Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键 // "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。 func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 4695ea07e651..aa065dbe04fd 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -170,33 +170,34 @@ func registerSystemPromptRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { prompts.GET("/config", h.Admin.SystemPrompt.Config) prompts.PUT("/config", h.Admin.SystemPrompt.SaveConfig) - prompts.GET("", h.Admin.SystemPrompt.List) - prompts.POST("", h.Admin.SystemPrompt.Create) - prompts.GET("/runtime", h.Admin.SystemPrompt.Runtime) - prompts.PUT("/runtime", h.Admin.SystemPrompt.UpdateRuntime) + prompts.GET("/rules/:rule_id/history", h.Admin.SystemPrompt.RuleHistory) + prompts.GET("", h.Admin.SystemPrompt.Retired) + prompts.POST("", h.Admin.SystemPrompt.Retired) + prompts.GET("/runtime", h.Admin.SystemPrompt.Retired) + prompts.PUT("/runtime", h.Admin.SystemPrompt.Retired) prompts.GET("/rules", h.Admin.SystemPrompt.Rules) - prompts.PUT("/rules", h.Admin.SystemPrompt.UpdateRules) + prompts.PUT("/rules", h.Admin.SystemPrompt.Retired) prompts.POST("/accounts/resolve", h.Admin.SystemPrompt.AccountBindings) prompts.POST("/accounts/bindings", h.Admin.SystemPrompt.UpdateAccountBindings) - prompts.POST("/rules/preview/:account_id", h.Admin.SystemPrompt.PreviewRules) - prompts.POST("/preview/merge", h.Admin.SystemPrompt.PreviewMerge) - prompts.POST("/preview/upstream", h.Admin.SystemPrompt.PreviewUpstream) - prompts.GET("/skill-registry", h.Admin.SystemPrompt.SkillRegistry) - prompts.GET("/skill-registry/versions", h.Admin.SystemPrompt.SkillVersions) - prompts.GET("/skill-registry/versions/:bundle_version_id", h.Admin.SystemPrompt.SkillVersion) - prompts.POST("/skill-registry/syncs", h.Admin.SystemPrompt.StartSkillSync) - prompts.GET("/skill-registry/syncs/:sync_id", h.Admin.SystemPrompt.SkillSync) - prompts.POST("/skill-registry/versions/:bundle_version_id/publish", h.Admin.SystemPrompt.PublishSkillVersion) - prompts.POST("/skill-registry/versions/:bundle_version_id/rollback", h.Admin.SystemPrompt.RollbackSkillVersion) - prompts.GET("/:id", h.Admin.SystemPrompt.Get) - prompts.GET("/:id/versions", h.Admin.SystemPrompt.Versions) - prompts.PATCH("/:id", h.Admin.SystemPrompt.Update) - prompts.DELETE("/:id", h.Admin.SystemPrompt.Delete) - prompts.POST("/:id/duplicate", h.Admin.SystemPrompt.Duplicate) - prompts.POST("/:id/versions", h.Admin.SystemPrompt.SaveVersion) - prompts.POST("/:id/upstream-sync", h.Admin.SystemPrompt.SyncManagedSource) - prompts.POST("/:id/versions/:version_id/publish", h.Admin.SystemPrompt.Publish) - prompts.POST("/:id/versions/:version_id/rollback", h.Admin.SystemPrompt.Rollback) + prompts.POST("/rules/preview/:account_id", h.Admin.SystemPrompt.Retired) + prompts.POST("/preview/merge", h.Admin.SystemPrompt.Retired) + prompts.POST("/preview/upstream", h.Admin.SystemPrompt.Retired) + prompts.GET("/skill-registry", h.Admin.SystemPrompt.Retired) + prompts.GET("/skill-registry/versions", h.Admin.SystemPrompt.Retired) + prompts.GET("/skill-registry/versions/:bundle_version_id", h.Admin.SystemPrompt.Retired) + prompts.POST("/skill-registry/syncs", h.Admin.SystemPrompt.Retired) + prompts.GET("/skill-registry/syncs/:sync_id", h.Admin.SystemPrompt.Retired) + prompts.POST("/skill-registry/versions/:bundle_version_id/publish", h.Admin.SystemPrompt.Retired) + prompts.POST("/skill-registry/versions/:bundle_version_id/rollback", h.Admin.SystemPrompt.Retired) + prompts.GET("/:id", h.Admin.SystemPrompt.Retired) + prompts.GET("/:id/versions", h.Admin.SystemPrompt.Retired) + prompts.PATCH("/:id", h.Admin.SystemPrompt.Retired) + prompts.DELETE("/:id", h.Admin.SystemPrompt.Retired) + prompts.POST("/:id/duplicate", h.Admin.SystemPrompt.Retired) + prompts.POST("/:id/versions", h.Admin.SystemPrompt.Retired) + prompts.POST("/:id/upstream-sync", h.Admin.SystemPrompt.Retired) + prompts.POST("/:id/versions/:version_id/publish", h.Admin.SystemPrompt.Retired) + prompts.POST("/:id/versions/:version_id/rollback", h.Admin.SystemPrompt.Retired) } } @@ -697,6 +698,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpA adminSettings.GET("/cindy-provider/catalog", h.Admin.Setting.GetCindyProviderCatalog) adminSettings.GET("/codex-runtime", h.Admin.Setting.GetNativeCodexConfiguration) adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateNativeCodexConfiguration) + adminSettings.POST("/openai-codex-ticket/proxy-parse", h.Admin.Setting.ParseCodexTicketProxy) adminSettings.POST("/openai-codex-ticket/proxy-test", h.Admin.Setting.TestCodexTicketProxy) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) diff --git a/backend/internal/server/routes/system_prompt_routes_test.go b/backend/internal/server/routes/system_prompt_routes_test.go index a2d72650a61e..57001ce9cd26 100644 --- a/backend/internal/server/routes/system_prompt_routes_test.go +++ b/backend/internal/server/routes/system_prompt_routes_test.go @@ -16,7 +16,7 @@ func TestRemoteSkillPromptVersionDetailRequiresAdminAuthentication(t *testing.T) gin.SetMode(gin.TestMode) router := gin.New() handlers := &handler.Handlers{Admin: &handler.AdminHandlers{ - SystemPrompt: adminhandler.NewSystemPromptHandler(nil, nil), + SystemPrompt: adminhandler.NewSystemPromptHandler(nil), }} adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) { if c.GetHeader("Authorization") == "" { @@ -49,3 +49,41 @@ func TestRemoteSkillPromptVersionDetailRequiresAdminAuthentication(t *testing.T) }) } } + +func TestRetiredPromptManagementRemainsInsideAdminAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + handlers := &handler.Handlers{Admin: &handler.AdminHandlers{ + SystemPrompt: adminhandler.NewSystemPromptHandler(nil), + }} + group := router.Group("/api/v1/admin", func(c *gin.Context) { + if c.GetHeader("Authorization") != "Bearer admin-token" { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Next() + }) + registerSystemPromptRoutes(group, handlers) + for _, route := range []struct{ method, path string }{ + {http.MethodPost, "/api/v1/admin/system-prompts/skill-registry/syncs"}, + {http.MethodPost, "/api/v1/admin/system-prompts/1/versions/2/publish"}, + {http.MethodPut, "/api/v1/admin/system-prompts/rules"}, + {http.MethodGet, "/api/v1/admin/system-prompts/1/versions"}, + } { + t.Run(route.method+" "+route.path, func(t *testing.T) { + request := httptest.NewRequest(route.method, route.path, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + request = httptest.NewRequest(route.method, route.path, nil) + request.Header.Set("Authorization", "Bearer admin-token") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusGone, recorder.Code) + }) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system-prompts/rules/example/history", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusUnauthorized, recorder.Code) +} diff --git a/backend/internal/service/account_business_errors.go b/backend/internal/service/account_business_errors.go index da464e47a24a..5bb31001a6e0 100644 --- a/backend/internal/service/account_business_errors.go +++ b/backend/internal/service/account_business_errors.go @@ -34,10 +34,23 @@ type accountBusinessMessage struct { } var accountBusinessMessageCatalog = map[string]accountBusinessMessage{ + "test_network_failed": {message: "connection to the upstream could not be established", failure: true}, + "test_authentication_failed": {message: "upstream rejected this account's authentication or access", failure: true}, + "test_rate_limited": {message: "upstream rate limited the connection test", failure: true}, + "test_upstream_failed": {message: "upstream rejected the connection test", failure: true}, "test_unavailable": {message: "account testing is unavailable", failure: true}, "test_failed": {message: "account connection test failed or did not complete", failure: true}, "test_timeout": {message: "account connection test timed out", failure: true}, "test_model_unsupported": {message: "selected model is not supported by this account", failure: true}, + "test_catalog_failed": {message: "account test model catalog is unavailable", failure: true}, + "test_no_text_model": {message: "account has no model for a text connection test", failure: true}, + "test_reasoning_unsupported": {message: "selected reasoning setting is not supported", failure: true}, + "test_plan_changed": {message: "connection test plan changed; configure a new test", failure: true}, + "test_plan_persist_failed": {message: "connection test plan could not be saved before execution", failure: true}, + "test_incomplete": {message: "connection stream ended before a recognized terminal state", failure: true}, + "test_empty_response": {message: "connection completed without visible text", failure: true}, + "test_terminal_failed": {message: "upstream reported an unsuccessful terminal state", failure: true}, + "test_protocol_invalid": {message: "invalid connection test protocol response", failure: true}, AccountJobCodePayloadExpired: {message: "account job payload expired", failure: true}, AccountJobCodePayloadUnavailable: {message: "account job payload is unavailable", failure: true}, AccountJobCodeCancelCheckFailed: {message: "account job cancellation state is unavailable", failure: true}, diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index 18c39946f26f..bd9246a30b32 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -61,27 +61,29 @@ var ( ) type AccountJob struct { - ID int64 `json:"id"` - CreatedBy int64 `json:"created_by"` - Kind string `json:"kind"` - IdempotencyKey string `json:"-"` - RequestHash string `json:"-"` - Status string `json:"status"` - Metadata json.RawMessage `json:"metadata"` - TargetCount int `json:"target_count"` - ProcessedCount int `json:"processed_count"` - SucceededCount int `json:"succeeded_count"` - FailedCount int `json:"failed_count"` - CanceledCount int `json:"canceled_count"` - CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - RetryOfJobID *int64 `json:"retry_of_job_id,omitempty"` - Attempt int `json:"attempt"` - 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"` + ID int64 `json:"id"` + CreatedBy int64 `json:"created_by"` + Kind string `json:"kind"` + IdempotencyKey string `json:"-"` + RequestHash string `json:"-"` + Status string `json:"status"` + Metadata json.RawMessage `json:"metadata"` + TargetCount int `json:"target_count"` + ProcessedCount int `json:"processed_count"` + SucceededCount int `json:"succeeded_count"` + FailedCount int `json:"failed_count"` + CanceledCount int `json:"canceled_count"` + CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + RetryOfJobID *int64 `json:"retry_of_job_id,omitempty"` + Attempt int `json:"attempt"` + 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"` + RetryEligible bool `json:"retry_eligible"` + RetryUnavailableReason string `json:"retry_unavailable_reason,omitempty"` } type AccountJobItem struct { @@ -287,11 +289,81 @@ func (s *AccountJobService) findMatchingSubmission(ctx context.Context, createdB } func (s *AccountJobService) Get(ctx context.Context, jobID int64) (*AccountJob, error) { - return s.repo.Get(ctx, jobID) + job, err := s.repo.Get(ctx, jobID) + if err == nil { + s.decorateRetryEligibility(ctx, job) + } + return job, err } func (s *AccountJobService) List(ctx context.Context, createdBy int64, kind, status string, page, pageSize int) (*AccountJobList, error) { - return s.repo.List(ctx, createdBy, kind, status, page, pageSize) + list, err := s.repo.List(ctx, createdBy, kind, status, page, pageSize) + if err == nil && list != nil { + for i := range list.Items { + s.decorateRetryEligibility(ctx, &list.Items[i]) + } + } + return list, err +} + +func AccountJobHasRetryableFailures(job *AccountJob) bool { + return job != nil && job.FailedCount > 0 && (job.Status == AccountJobStatusFailed || job.Status == AccountJobStatusPartiallySucceeded || job.Status == AccountJobStatusCanceled) +} + +func (s *AccountJobService) decorateRetryEligibility(ctx context.Context, job *AccountJob) { + if job == nil { + return + } + job.RetryEligible = false + if !AccountJobHasRetryableFailures(job) { + return + } + cipher, expires, err := s.repo.Payload(ctx, job.ID) + if err != nil || cipher == "" || !time.Now().UTC().Before(expires) { + job.RetryUnavailableReason = "payload_expired" + return + } + job.RetryEligible = true + job.RetryUnavailableReason = "" +} + +// The durable plan is written while the item is running, before any model IO. +// Keeping this capability separate preserves non-test executors and repositories. +type AccountJobExecutionSnapshotRepository interface { + SaveExecutionSnapshot(context.Context, int64, int64, json.RawMessage) error +} + +func (s *AccountJobService) SaveExecutionSnapshot(ctx context.Context, jobID, itemID int64, metadata json.RawMessage) error { + if err := ValidateAccountJobMetadata(metadata); err != nil { + return err + } + if s != nil { + if repo, ok := s.repo.(AccountJobExecutionSnapshotRepository); ok { + return repo.SaveExecutionSnapshot(ctx, jobID, itemID, metadata) + } + } + return errors.New("account test execution snapshot is unavailable") +} + +// Stop and internal failure take precedence over progress counters. A monitor +// failure after all item writes must never turn the overall task green. +func AccountJobTerminalStatus(cancelRequested bool, errorCode string, succeeded, failed, canceled int) string { + if cancelRequested { + return AccountJobStatusCanceled + } + if errorCode != "" { + return AccountJobStatusFailed + } + if canceled > 0 { + return AccountJobStatusCanceled + } + if failed == 0 { + return AccountJobStatusSucceeded + } + if succeeded > 0 { + return AccountJobStatusPartiallySucceeded + } + return AccountJobStatusFailed } func (s *AccountJobService) ListItems(ctx context.Context, jobID int64, status string, page, pageSize int) (*AccountJobItemList, error) { @@ -306,7 +378,11 @@ func (s *AccountJobService) ResultAccountIDs(ctx context.Context, jobID int64) ( } func (s *AccountJobService) Cancel(ctx context.Context, jobID, createdBy int64) (*AccountJob, error) { - return s.repo.Cancel(ctx, jobID, createdBy) + job, err := s.repo.Cancel(ctx, jobID, createdBy) + if err == nil { + s.decorateRetryEligibility(ctx, job) + } + return job, err } func (s *AccountJobService) RetryFailed(ctx context.Context, jobID, createdBy int64, idempotencyKey string) (*AccountJob, bool, error) { diff --git a/backend/internal/service/account_job_batch_test_runtime.go b/backend/internal/service/account_job_batch_test_runtime.go index 12b450513274..24f2b5fa1dc0 100644 --- a/backend/internal/service/account_job_batch_test_runtime.go +++ b/backend/internal/service/account_job_batch_test_runtime.go @@ -85,6 +85,8 @@ func (r *AccountJobRuntime) executeBatchTests(parent context.Context, job *Accou } wg.Wait() } + cancel() + <-monitorDone select { case code := <-failures: return normalizeAccountJobFailure(code) diff --git a/backend/internal/service/account_test_prompt.go b/backend/internal/service/account_test_prompt.go index 94872fbd8574..0d0214170576 100644 --- a/backend/internal/service/account_test_prompt.go +++ b/backend/internal/service/account_test_prompt.go @@ -18,7 +18,9 @@ func accountTextTestOutputLimit(prompts ...string) int { return 1024 } } - return 1 + // A one-token cap can stop in whitespace or reasoning before any visible + // text. Connectivity requires a displayable reply, so reserve a small budget. + return 256 } const accountTestPromptContextKey = "account_test_user_prompt" @@ -26,7 +28,7 @@ const accountTestScheduledDefaultsContextKey = "account_test_scheduled_defaults" func resolveAntigravityTestPrompt(prompts ...string) string { if len(prompts) == 0 { - return "." + return "Reply with OK." } return resolveAccountTestPrompt(prompts...) } @@ -64,7 +66,7 @@ func resolveAccountTestPrompt(prompts ...string) string { if len(prompts) > 0 && strings.TrimSpace(prompts[0]) != "" { return prompts[0] } - return "hi" + return "Reply with OK." } func accountTestUserPrompt(c *gin.Context) string { diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go index dc5d8c077b89..0e5bdd535282 100644 --- a/backend/internal/service/account_test_prompt_test.go +++ b/backend/internal/service/account_test_prompt_test.go @@ -20,7 +20,7 @@ func TestAccountTestPromptBuildersPreserveUserText(t *testing.T) { prompt := " 你的知识库库截止日期是什么时间,直接回复不要联网\n" require.NoError(t, ValidateAccountTestPrompt(strings.Repeat("😀", 8192))) require.Error(t, ValidateAccountTestPrompt(strings.Repeat("😀", 8193))) - require.Equal(t, "hi", resolveAccountTestPrompt(" \n")) + require.Equal(t, "Reply with OK.", resolveAccountTestPrompt(" \n")) claude, err := createTestPayload("claude-sonnet-4-6", prompt) require.NoError(t, err) raw, _ := json.Marshal(claude) @@ -57,8 +57,8 @@ func TestAccountTestPromptAntigravityScheduledDefaults(t *testing.T) { want string limit int64 }{ - {"scheduled", nil, ".", 1}, - {"blank manual", []string{""}, "hi", 1}, + {"scheduled", nil, "Reply with OK.", 256}, + {"blank manual", []string{""}, "Reply with OK.", 256}, {"custom manual", []string{"custom question"}, "custom question", 1024}, } { t.Run(input.name, func(t *testing.T) { @@ -108,7 +108,7 @@ func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) { a.Credentials["header_override_enabled"] = true a.Credentials["header_overrides"] = map[string]any{openAICodexTurnStateHeader: fakeCodexTicketState(312)} a.Extra = map[string]any{openAICodexTicketExtraKey("gpt-5.6-sol"): &CodexTicketRecord{State: fakeCodexTicketState(292), Length: 292, AccountID: a.ID, Model: "gpt-5.6-sol", ExpiresAt: time.Now().Add(time.Hour)}} - upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.completed\"}\n\n")}} + upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")}} gateway := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, accountRepo: &routingAccountRepositoryFixture{account: a}} // Cookie routing replaced the 292 header: the plugin only references a // host-verified qualification, gated like business traffic on the final model. diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go index 98096c40445c..25051bc97217 100644 --- a/backend/internal/service/account_test_reasoning_wire_test.go +++ b/backend/internal/service/account_test_reasoning_wire_test.go @@ -24,7 +24,7 @@ func (r *reasoningTestRepo) UpdateExtra(context.Context, int64, map[string]any) func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) { a := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"access_token": "test-access", "model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} - upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.completed\"}\n\n")}} + upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")}} svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: a}, httpUpstream: upstream, cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}}} c, rec := newTestContext() c.Request = c.Request.WithContext(withCodexTransportFixture(c.Request.Context(), true)) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a405d574ae1b..491c99ffd26c 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -1,7 +1,6 @@ package service import ( - "bufio" "bytes" "context" "crypto/rand" @@ -22,7 +21,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "regexp" "strings" "sync" "time" @@ -41,10 +39,6 @@ import ( "github.com/tidwall/gjson" ) -// sseDataPrefix matches SSE data lines with optional whitespace after colon. -// Some upstream APIs return non-standard "data:" without space (should be "data: "). -var sseDataPrefix = regexp.MustCompile(`^data:\s*`) - const ( testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" @@ -52,6 +46,7 @@ const ( // TestEvent represents a SSE event for account testing type TestEvent struct { + OutputLimited bool `json:"output_limited,omitempty"` RequestedReasoningEffort string `json:"requested_reasoning_effort,omitempty"` EffectiveReasoningEffort string `json:"effective_reasoning_effort,omitempty"` Type string `json:"type"` @@ -72,6 +67,9 @@ type TestEvent struct { // AccountTestOptions carries optional media for admin connectivity tests. // ImageDataURL / AudioDataURL are full data URLs (data:;base64,...). type AccountTestOptions struct { + ExpectedMappedModel string + ExpectedWirePlatform string + ExpectedAPIProtocol string ReasoningEffort string ImageDataURL string AudioDataURL string @@ -407,6 +405,14 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int if err := EnsureCindyProviderAvailable(ctx, account); err != nil { return s.sendErrorAndEnd(c, err.Error()) } + if testOpts.ExpectedMappedModel != "" { + actualModel, mappingErr := ResolveAccountTestExecutionModel(ctx, account, modelID) + if mappingErr != nil || actualModel != testOpts.ExpectedMappedModel || account.EffectiveWirePlatform() != testOpts.ExpectedWirePlatform || account.GetAPIProtocol() != testOpts.ExpectedAPIProtocol { + s.sendEvent(c, TestEvent{Type: "error", Code: "test_plan_changed", Error: ErrAccountTestPlanChanged.Error()}) + return ErrAccountTestPlanChanged + } + } + c.Set("account_test_allow_media", !accountTestUsesTextPrompt(modelID, mode)) if err := validateAccountPromptExtension(ctx, account, prompt, modelID, mode); err != nil { return s.sendErrorAndEnd(c, err.Error()) } @@ -599,7 +605,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() @@ -612,7 +618,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, errMsg) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } // Process SSE stream @@ -671,7 +677,7 @@ func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Con resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() @@ -681,7 +687,7 @@ func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Con if resp.StatusCode == http.StatusForbidden { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, errMsg) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } return s.processClaudeStream(c, resp.Body) @@ -757,19 +763,20 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { - return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } // Bedrock non-streaming response is standard Claude JSON, extract the text var result struct { - Content []struct { + StopReason string `json:"stop_reason"` + Content []struct { Text string `json:"text"` } `json:"content"` } @@ -777,16 +784,19 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error())) } - text := "" - if len(result.Content) > 0 { - text = result.Content[0].Text + state := connectionStreamState{protocol: "claude", stopReason: result.StopReason, emit: func(event TestEvent) { s.sendEvent(c, event) }} + for _, part := range result.Content { + state.text(part.Text) } - if text == "" { - text = "(empty response)" + if result.StopReason == "" { + s.sendEvent(c, TestEvent{Type: "error", Code: "test_incomplete", Error: ErrAccountTestIncomplete.Error()}) + return ErrAccountTestIncomplete } - - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + if _, err := state.consume(map[string]any{"type": "message_stop"}); err != nil { + s.sendEvent(c, TestEvent{Type: "error", Code: AccountTestFailureCode(err), Error: err.Error()}) + return err + } + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true, OutputLimited: state.limited}) return nil } @@ -913,7 +923,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account // Send test_start event once. A task-invalid Agent Identity response may // restart this probe after registering a replacement task. if !agentIdentityTaskRecoveryWasTried(ctx) { - s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + s.sendEvent(c, TestEvent{Type: "test_start", Model: upstreamTestModelID}) } req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes)) @@ -997,7 +1007,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account s.sendEvent(c, TestEvent{Type: "status", Text: snapshot.summary(), Data: snapshot}) } if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() @@ -1028,7 +1038,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body)) _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } // Process SSE stream @@ -1331,15 +1341,15 @@ func (s *AccountTestService) testGrokResponsesConnection(c *gin.Context, ctx con resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err, accountTestEndpointGrokResponses) } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(withGrokTeamRateLimitModel(ctx, testModelID), account, resp) if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) + _, _ = io.Copy(io.Discard, resp.Body) + return s.sendAccountTestHTTPError(c, resp.StatusCode, accountTestEndpointGrokResponses) } return s.processOpenAIStream(c, ctx, account, resp.Body) @@ -2222,7 +2232,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err, accountTestEndpointChat) } defer func() { _ = resp.Body.Close() }() @@ -2236,7 +2246,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( errMsg := fmt.Sprintf("Chat Completions authentication failed (401): %s", string(body)) _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) returned %d: %s", resp.StatusCode, string(body))) + return s.sendAccountTestHTTPError(c, resp.StatusCode, accountTestEndpointChat) } return s.processOpenAIChatCompletionsStream(c, ctx, account, resp.Body) @@ -2555,13 +2565,13 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) + _, _ = io.Copy(io.Discard, resp.Body) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } // Process SSE stream @@ -2608,15 +2618,16 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac } result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID, prompts...) if err != nil { - return s.sendErrorAndEnd(c, err.Error()) + return s.sendAccountTestFailure(c, err) } + s.sendEvent(c, TestEvent{Type: "test_start", Model: result.MappedModel}) // 发送响应内容 if result.Text != "" { s.sendEvent(c, TestEvent{Type: "content", Text: result.Text}) } - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true, OutputLimited: result.OutputLimited}) return nil } @@ -2799,83 +2810,7 @@ func createGeminiTestPayload(modelID string, prompt string) []byte { // processGeminiStream processes SSE stream from Gemini API func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader) error { - reader := bufio.NewReader(body) - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error())) - } - - line = strings.TrimSpace(line) - if line == "" || !strings.HasPrefix(line, "data: ") { - continue - } - - jsonStr := strings.TrimPrefix(line, "data: ") - if jsonStr == "[DONE]" { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - - var data map[string]any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - continue - } - - // Support two Gemini response formats: - // - AI Studio: {"candidates": [...]} - // - Gemini CLI: {"response": {"candidates": [...]}} - if resp, ok := data["response"].(map[string]any); ok && resp != nil { - data = resp - } - if candidates, ok := data["candidates"].([]any); ok && len(candidates) > 0 { - if candidate, ok := candidates[0].(map[string]any); ok { - // Extract content first (before checking completion) - if content, ok := candidate["content"].(map[string]any); ok { - if parts, ok := content["parts"].([]any); ok { - for _, part := range parts { - if partMap, ok := part.(map[string]any); ok { - if text, ok := partMap["text"].(string); ok && text != "" { - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - } - if inlineData, ok := partMap["inlineData"].(map[string]any); ok { - mimeType, _ := inlineData["mimeType"].(string) - data, _ := inlineData["data"].(string) - if strings.HasPrefix(strings.ToLower(mimeType), "image/") && data != "" { - s.sendEvent(c, TestEvent{ - Type: "image", - ImageURL: fmt.Sprintf("data:%s;base64,%s", mimeType, data), - MimeType: mimeType, - }) - } - } - } - } - } - } - - // Check for completion after extracting content - if finishReason, ok := candidate["finishReason"].(string); ok && finishReason != "" { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - } - } - - // Handle errors - if errData, ok := data["error"].(map[string]any); ok { - errorMsg := "Unknown error" - if msg, ok := errData["message"].(string); ok { - errorMsg = msg - } - return s.sendErrorAndEnd(c, errorMsg) - } - } + return s.processConnectionStream(c, body, "gemini", nil) } // createOpenAITestPayload creates a test payload for OpenAI Responses API @@ -2924,207 +2859,18 @@ func createOpenAIChatCompletionsTestPayload(modelID string, prompt string) map[s // processClaudeStream processes the SSE stream from Claude API func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) error { - reader := bufio.NewReader(body) - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error())) - } - - line = strings.TrimSpace(line) - if line == "" || !sseDataPrefix.MatchString(line) { - continue - } - - jsonStr := sseDataPrefix.ReplaceAllString(line, "") - if jsonStr == "[DONE]" { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - - var data map[string]any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - continue - } - - eventType, _ := data["type"].(string) - - switch eventType { - case "content_block_delta": - if delta, ok := data["delta"].(map[string]any); ok { - if text, ok := delta["text"].(string); ok { - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - } - } - case "message_stop": - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - case "error": - errorMsg := "Unknown error" - if errData, ok := data["error"].(map[string]any); ok { - if msg, ok := errData["message"].(string); ok { - errorMsg = msg - } - } - return s.sendErrorAndEnd(c, errorMsg) - } - } + return s.processConnectionStream(c, body, "claude", nil) } // processOpenAIChatCompletionsStream processes SSE chunks from the // OpenAI-compatible Chat Completions API. func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, ctx context.Context, account *Account, body io.Reader) error { - reader := bufio.NewReader(body) - seenJSON := false - seenFinish := false - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - if seenFinish { - s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - if seenJSON { - return s.sendErrorAndEnd(c, "Chat Completions stream from /v1/chat/completions ended before [DONE]") - } - return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected SSE JSON data") - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions stream read error from /v1/chat/completions: %s", err.Error())) - } - - line = strings.TrimSpace(line) - if line == "" || !sseDataPrefix.MatchString(line) { - continue - } - - jsonStr := sseDataPrefix.ReplaceAllString(line, "") - if jsonStr == "[DONE]" { - s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - - var data map[string]any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected JSON data") - } - seenJSON = true - if eventType, _ := data["type"].(string); eventType == "error" || eventType == "response.failed" { - s.markCindyBalanceInsufficientFromTest(ctx, account, http.StatusOK, []byte(jsonStr)) - } - - if errData, ok := data["error"].(map[string]any); ok { - errorMsg := "Chat Completions API (/v1/chat/completions) returned an error" - if msg, ok := errData["message"].(string); ok && msg != "" { - errorMsg = msg - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) error: %s", errorMsg)) - } - - choices, ok := data["choices"].([]any) - if !ok { - continue - } - for _, choiceValue := range choices { - choice, ok := choiceValue.(map[string]any) - if !ok { - continue - } - if delta, ok := choice["delta"].(map[string]any); ok { - if text, ok := delta["content"].(string); ok && text != "" { - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - } - } - if message, ok := choice["message"].(map[string]any); ok { - if text, ok := message["content"].(string); ok && text != "" { - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - } - } - if finishReason, ok := choice["finish_reason"].(string); ok && finishReason != "" { - seenFinish = true - } - } - } + return s.processConnectionStream(c, body, "chat", account) } // processOpenAIStream processes the SSE stream from OpenAI Responses API func (s *AccountTestService) processOpenAIStream(c *gin.Context, ctx context.Context, account *Account, body io.Reader) error { - reader := bufio.NewReader(body) - seenCompleted := false - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - if seenCompleted { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - return s.sendErrorAndEnd(c, "Stream ended before response.completed") - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error())) - } - - line = strings.TrimSpace(line) - if line == "" || !sseDataPrefix.MatchString(line) { - continue - } - - jsonStr := sseDataPrefix.ReplaceAllString(line, "") - if jsonStr == "[DONE]" { - if seenCompleted { - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } - return s.sendErrorAndEnd(c, "Stream ended before response.completed") - } - - var data map[string]any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - continue - } - - eventType, _ := data["type"].(string) - - switch eventType { - case "response.output_text.delta": - // OpenAI Responses API uses "delta" field for text content - if delta, ok := data["delta"].(string); ok && delta != "" { - s.sendEvent(c, TestEvent{Type: "content", Text: delta}) - } - case "response.completed", "response.done": - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - case "response.failed": - s.markCindyBalanceInsufficientFromTest(ctx, account, http.StatusOK, []byte(jsonStr)) - errorMsg := "OpenAI response failed" - if responseData, ok := data["response"].(map[string]any); ok { - if errData, ok := responseData["error"].(map[string]any); ok { - if msg, ok := errData["message"].(string); ok && msg != "" { - errorMsg = msg - } - } - } - return s.sendErrorAndEnd(c, errorMsg) - case "error": - s.markCindyBalanceInsufficientFromTest(ctx, account, http.StatusOK, []byte(jsonStr)) - errorMsg := "Unknown error" - if errData, ok := data["error"].(map[string]any); ok { - if msg, ok := errData["message"].(string); ok { - errorMsg = msg - } - } - return s.sendErrorAndEnd(c, errorMsg) - } - } + return s.processConnectionStream(c, body, "responses", account) } // testOpenAIImageAPIKey tests OpenAI image generation using an API Key account. @@ -3464,19 +3210,24 @@ func (s *AccountTestService) sendErrorAndEnd(c *gin.Context, errorMsg string) er // Background tests consume the same typed events as the HTTP SSE adapter. // Receiving text or reaching EOF alone never proves that a test completed. type accountTestEventCollector struct { - text strings.Builder - errorMessage string - completed bool + text strings.Builder + errorMessage string + completed bool + outputLimited bool + actualModel string } func (c *accountTestEventCollector) Add(event TestEvent) { switch event.Type { + case "test_start": + c.actualModel = event.Model case "content": _, _ = c.text.WriteString(event.Text) case "error": c.errorMessage = event.Error case "test_complete": c.completed = event.Success + c.outputLimited = event.OutputLimited } } func (s *AccountTestService) RunTestBackground(ctx context.Context, accountID int64, modelID string) (*ScheduledTestResult, error) { @@ -3507,11 +3258,14 @@ func (s *AccountTestService) runTestBackgroundWithOptions(ctx context.Context, a ginCtx.Set("account_test_event_collector", collector) ginCtx.Set(accountTestScheduledDefaultsContextKey, !opts.requireSupportedModel) testErr := s.TestAccountConnection(ginCtx, accountID, modelID, prompt, AccountTestModeDefault, opts) - if testErr == nil { + if testErr == nil && !collector.completed { testErr = ctx.Err() + if testErr == nil { + testErr = ErrAccountTestIncomplete + } } - if testErr == nil && !collector.completed { - testErr = errors.New("test did not emit a successful completion") + if testErr == nil && strings.TrimSpace(collector.text.String()) == "" { + testErr = ErrAccountTestEmpty } status := "success" if testErr != nil || collector.errorMessage != "" { @@ -3522,6 +3276,7 @@ func (s *AccountTestService) runTestBackgroundWithOptions(ctx context.Context, a } finishedAt := time.Now() return &ScheduledTestResult{Status: status, ResponseText: collector.text.String(), ErrorMessage: collector.errorMessage, + OutputLimited: collector.outputLimited, ActualModel: collector.actualModel, RequestedReasoningEffort: opts.ReasoningEffort, EffectiveReasoningEffort: ginCtx.GetString("account_test_effective_reasoning_effort"), LatencyMs: finishedAt.Sub(startedAt).Milliseconds(), StartedAt: startedAt, FinishedAt: finishedAt}, testErr } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index eceb0dc41c72..7a3ff8958fe7 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -1,7 +1,6 @@ package service import ( - "bufio" "bytes" "encoding/json" "fmt" @@ -90,7 +89,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err, accountTestEndpointAdaptiveAnthropic) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { @@ -99,7 +98,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, errMsg) + return s.sendAccountTestHTTPError(c, resp.StatusCode, accountTestEndpointAdaptiveAnthropic) } if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil { @@ -110,48 +109,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co } func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error { - reader := bufio.NewReader(body) - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop") - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error())) - } - - line = strings.TrimSpace(line) - if line == "" || !sseDataPrefix.MatchString(line) { - continue - } - jsonStr := sseDataPrefix.ReplaceAllString(line, "") - if jsonStr == "[DONE]" { - return nil - } - - var data map[string]any - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - continue - } - switch eventType, _ := data["type"].(string); eventType { - case "content_block_delta": - if delta, ok := data["delta"].(map[string]any); ok { - if text, ok := delta["text"].(string); ok && text != "" { - s.sendEvent(c, TestEvent{Type: "content", Text: text}) - } - } - case "message_stop": - return nil - case "error": - errorMsg := "Unknown error" - if errData, ok := data["error"].(map[string]any); ok { - if message, ok := errData["message"].(string); ok && message != "" { - errorMsg = message - } - } - return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg)) - } - } + return s.processConnectionStream(c, body, "claude", nil) } func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { @@ -185,7 +143,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err, accountTestEndpointAdaptiveResponses) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { @@ -194,7 +152,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, errMsg) + return s.sendAccountTestHTTPError(c, resp.StatusCode, accountTestEndpointAdaptiveResponses) } if err := s.processOpenAIStream(c, ctx, account, resp.Body); err != nil { @@ -275,7 +233,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Anthropic endpoint request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err, accountTestEndpointAnthropic) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { @@ -284,7 +242,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } - return s.sendErrorAndEnd(c, errMsg) + return s.sendAccountTestHTTPError(c, resp.StatusCode, accountTestEndpointAnthropic) } return s.processClaudeStream(c, resp.Body) diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go index ffbc497ced48..f935be654ede 100644 --- a/backend/internal/service/account_test_service_cn_adaptive_test.go +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -182,7 +182,7 @@ func TestAccountTestService_AdaptiveRejectsInvalidAnthropicSuccessBody(t *testin err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) require.Error(t, err) - require.Contains(t, err.Error(), "Adaptive Anthropic stream ended before message_stop") + require.ErrorIs(t, err, ErrAccountTestIncomplete) require.Len(t, upstream.requests, 2) require.Contains(t, recorder.Body.String(), `"type":"error"`) require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) diff --git a/backend/internal/service/account_test_service_gemini_test.go b/backend/internal/service/account_test_service_gemini_test.go index f38264a215d2..2496dcc7788d 100644 --- a/backend/internal/service/account_test_service_gemini_test.go +++ b/backend/internal/service/account_test_service_gemini_test.go @@ -44,8 +44,9 @@ func TestProcessGeminiStream_EmitsImageEvent(t *testing.T) { ctx, recorder := newTestContext() svc := &AccountTestService{} + ctx.Set("account_test_allow_media", true) - stream := strings.NewReader("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"QUJD\"}}]}}]}\n\ndata: [DONE]\n\n") + stream := strings.NewReader("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"QUJD\"}}]},\"finishReason\":\"STOP\"}]}\n\ndata: [DONE]\n\n") err := svc.processGeminiStream(ctx, stream) require.NoError(t, err) diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 9a0ebcaaf55b..1a989835a28a 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -304,7 +304,7 @@ func TestAccountTestService_CindyEmptyModelUsesLuna(t *testing.T) { c, _ := newTestContext() upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse( http.StatusOK, - "data: {\"type\":\"response.completed\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n", )}} svc := &AccountTestService{ httpUpstream: upstream, @@ -332,7 +332,7 @@ func TestAccountTestService_NonCindyEmptyModelKeepsOpenAIDefault(t *testing.T) { c, _ := newTestContext() upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse( http.StatusOK, - "data: {\"type\":\"response.completed\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n", )}} svc := &AccountTestService{ httpUpstream: upstream, @@ -360,7 +360,9 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing. ctx.Request = ctx.Request.WithContext(withCodexTransportFixture(ctx.Request.Context(), true)) resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) resp.Header.Set("x-codex-primary-used-percent", "88") @@ -406,7 +408,9 @@ func TestAccountTestService_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) { ctx, _ := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) @@ -434,7 +438,9 @@ func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *t ctx, recorder := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) @@ -510,7 +516,7 @@ func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) { err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") require.Error(t, err) - require.Contains(t, recorder.Body.String(), "response.completed") + require.ErrorIs(t, err, ErrAccountTestIncomplete) require.NotContains(t, recorder.Body.String(), `"success":true`) } @@ -519,7 +525,9 @@ func TestAccountTestService_DeepSeekCustomBaseURLUsesV1ResponsesPath(t *testing. ctx, _ := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} @@ -553,7 +561,9 @@ func TestAccountTestService_DeepSeekResponsesRoutesToOpenAIProbe(t *testing.T) { ctx, _ := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} @@ -593,7 +603,9 @@ func TestAccountTestService_DeepSeekDefaultBaseURLUsesNativeResponsesPath(t *tes ctx, _ := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} + +data: {"type":"response.completed"} `)) upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} @@ -953,7 +965,7 @@ func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testin ctx, _ := newTestContext() resp := newJSONResponse(http.StatusOK, "") - resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n")) + resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\"}\n\n")) upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} svc := &AccountTestService{ httpUpstream: upstream, @@ -1056,6 +1068,7 @@ func TestAccountTestService_OpenAIChatCompletionsPathReturns4xx(t *testing.T) { require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) returned 400") require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.Contains(t, recorder.Body.String(), "test_upstream_failed") require.NotContains(t, recorder.Body.String(), `"success":true`) } @@ -1086,6 +1099,7 @@ func TestAccountTestService_OpenAIChatCompletionsPathTimeout(t *testing.T) { require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) request failed") require.Contains(t, err.Error(), context.DeadlineExceeded.Error()) require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.Contains(t, recorder.Body.String(), "test_timeout") require.NotContains(t, recorder.Body.String(), `"success":true`) } @@ -1117,7 +1131,7 @@ func TestAccountTestService_OpenAIChatCompletionsPathRejectsNonJSONStream(t *tes err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") require.Error(t, err) require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) - require.Contains(t, err.Error(), "Invalid Chat Completions response from /v1/chat/completions") - require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.ErrorIs(t, err, ErrAccountTestProtocol) + require.Contains(t, recorder.Body.String(), "test_protocol_invalid") require.NotContains(t, recorder.Body.String(), `"success":true`) } diff --git a/backend/internal/service/account_test_service_opencode_go.go b/backend/internal/service/account_test_service_opencode_go.go index abbdd7b573c3..9376c69a168e 100644 --- a/backend/internal/service/account_test_service_opencode_go.go +++ b/backend/internal/service/account_test_service_opencode_go.go @@ -91,12 +91,12 @@ func (s *AccountTestService) testOpenCodeGoConnection(c *gin.Context, account *A } resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("OpenCode Go request failed: %s", err.Error())) + return s.sendAccountTestRequestError(c, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return s.sendErrorAndEnd(c, fmt.Sprintf("OpenCode Go API returned %d: %s", resp.StatusCode, string(body))) + _, _ = io.Copy(io.Discard, resp.Body) + return s.sendAccountTestHTTPError(c, resp.StatusCode) } if protocol == APIProtocolAnthropic { return s.processClaudeStream(c, resp.Body) diff --git a/backend/internal/service/account_test_stream.go b/backend/internal/service/account_test_stream.go new file mode 100644 index 000000000000..3d814d51ab0d --- /dev/null +++ b/backend/internal/service/account_test_stream.go @@ -0,0 +1,464 @@ +package service + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" +) + +var ( + ErrAccountTestIncomplete = errors.New("connection stream ended before a recognized terminal state") + ErrAccountTestEmpty = errors.New("connection completed without visible text") + ErrAccountTestTerminal = errors.New("upstream reported an unsuccessful terminal state") + ErrAccountTestProtocol = errors.New("invalid connection test protocol response") + ErrAccountTestPlanChanged = errors.New("connection test plan changed; configure a new test") +) + +func AccountTestFailureCode(err error) string { + var failure *accountTestTransportFailure + if errors.As(err, &failure) { + return failure.code + } + switch { + case errors.Is(err, ErrAccountTestModelUnsupported): + return "test_model_unsupported" + case errors.Is(err, ErrAccountTestPlanChanged): + return "test_plan_changed" + case errors.Is(err, ErrAccountTestIncomplete): + return "test_incomplete" + case errors.Is(err, ErrAccountTestEmpty): + return "test_empty_response" + case errors.Is(err, ErrAccountTestTerminal): + return "test_terminal_failed" + case errors.Is(err, ErrAccountTestProtocol): + return "test_protocol_invalid" + case errors.Is(err, context.DeadlineExceeded): + return "test_timeout" + default: + return "test_failed" + } +} + +// AccountTestSupportsTextConversation consumes only supplied catalog identities +// and local mappings. Catalog projection must not acquire another provider +// snapshot through Account.GetMappedModel or its compatibility fallbacks. +func AccountTestSupportsTextConversation(account *Account, model string, catalogTargets ...string) bool { + if account == nil { + return false + } + mapped := model + if len(catalogTargets) > 0 { + if catalogTargets[0] != "" { + mapped = catalogTargets[0] + } + } else { + mapping := account.GetModelMapping() + if target, matched := resolveRequestedModelInMapping(mapping, model); matched { + mapped = target + } else if target, matched := resolveRequestedModelInMapping(mapping, normalizeRequestedModelForLookup(account.Platform, model)); matched { + mapped = target + } + } + for _, id := range []string{model, mapped} { + if isOpenAIImageModel(id) || isGrokImageGenerationModel(id) || isGrokVideoGenerationModel(id) || isImageGenerationModel(id) { + return false + } + lower := strings.ToLower(id) + for _, prefix := range []string{"dall-e", "sora", "text-embedding-", "embedding-", "whisper-", "tts-", "omni-moderation-"} { + if strings.HasPrefix(lower, prefix) { + return false + } + } + for _, suffix := range []string{"-transcribe", "-tts", "-realtime", "-realtime-preview", "-voice-latest"} { + if strings.HasSuffix(lower, suffix) { + return false + } + } + } + return true +} + +type accountTestTransportFailure struct { + code string + message string + cause error +} + +func (e *accountTestTransportFailure) Error() string { return e.message } +func (e *accountTestTransportFailure) Unwrap() error { return e.cause } + +// Endpoint labels are supplied by the adapter, never from request URLs or +// provider error text. This retains protocol diagnostics without credentials. +type accountTestEndpoint string + +const ( + accountTestEndpointChat accountTestEndpoint = "Chat Completions API (/v1/chat/completions)" + accountTestEndpointAdaptiveAnthropic accountTestEndpoint = "Adaptive Anthropic endpoint" + accountTestEndpointAdaptiveResponses accountTestEndpoint = "Adaptive Responses endpoint" + accountTestEndpointAnthropic accountTestEndpoint = "Anthropic endpoint" + accountTestEndpointGrokResponses accountTestEndpoint = "Grok Responses API" +) + +func accountTestRequestFailure(err error, endpoints ...accountTestEndpoint) error { + code := "test_network_failed" + if errors.Is(err, context.DeadlineExceeded) { + code = "test_timeout" + } + message, _ := AccountBusinessMessage(code) + if errors.Is(err, context.DeadlineExceeded) { + message += " (" + context.DeadlineExceeded.Error() + ")" + } + if len(endpoints) > 0 && endpoints[0] != "" { + message = string(endpoints[0]) + " request failed: " + message + } + return &accountTestTransportFailure{code: code, message: message, cause: err} +} + +func accountTestHTTPFailure(status int, endpoints ...accountTestEndpoint) error { + code := "test_upstream_failed" + if status == http.StatusUnauthorized || status == http.StatusForbidden { + code = "test_authentication_failed" + } + if status == http.StatusTooManyRequests { + code = "test_rate_limited" + } + message, _ := AccountBusinessMessage(code) + label := "API" + if len(endpoints) > 0 && endpoints[0] != "" { + label = string(endpoints[0]) + } + return &accountTestTransportFailure{code: code, message: fmt.Sprintf("%s returned %d: %s", label, status, message)} +} + +// AccountTestSafeFailureMessage is also used by persisted batch results. Only +// the typed local formatter may add context to the fixed business catalog. +func AccountTestSafeFailureMessage(err error) string { + code := AccountTestFailureCode(err) + message, _ := AccountBusinessMessage(code) + var transport *accountTestTransportFailure + if errors.As(err, &transport) { + message = transport.message + } + return message +} + +func (s *AccountTestService) sendAccountTestFailure(c *gin.Context, err error) error { + s.sendEvent(c, TestEvent{Type: "error", Code: AccountTestFailureCode(err), Error: AccountTestSafeFailureMessage(err)}) + return err +} + +func (s *AccountTestService) sendAccountTestRequestError(c *gin.Context, err error, endpoints ...accountTestEndpoint) error { + return s.sendAccountTestFailure(c, accountTestRequestFailure(err, endpoints...)) +} + +func (s *AccountTestService) sendAccountTestHTTPError(c *gin.Context, status int, endpoints ...accountTestEndpoint) error { + return s.sendAccountTestFailure(c, accountTestHTTPFailure(status, endpoints...)) +} + +type connectionStreamState struct { + protocol string + visible bool + media bool + allowMedia bool + limited bool + stopReason string + emit func(TestEvent) +} + +// ResolveAccountTestExecutionModel mirrors the wire mappings of the probe +// adapters so their execution identity can be persisted before model IO. +func ResolveAccountTestExecutionModel(ctx context.Context, account *Account, model string) (string, error) { + if account == nil { + return "", ErrAccountTestPlanChanged + } + if IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) { + snapshot, err := LoadCindyCatalogSnapshot(ctx, account) + if err != nil { + return "", err + } + mapped := cindyAccountMappedModel(snapshot, account, model) + if target, ok := snapshot.CompatibilityMappings[mapped]; ok { + return target, nil + } + if target, ok := snapshot.AvailableMappings[mapped]; ok { + return target, nil + } + return mapped, nil + } + if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { + return (*AntigravityGatewayService)(nil).getMappedModel(account, model), nil + } + if account.IsGemini() || (account.Platform == PlatformAntigravity && strings.HasPrefix(model, "gemini-")) { + if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { + if mapped, ok := account.GetModelMapping()[model]; ok { + return mapped, nil + } + } + return model, nil + } + if account.IsBedrock() { + mapped, ok := ResolveBedrockModelID(account, model) + if !ok { + return "", ErrAccountTestModelUnsupported + } + return mapped, nil + } + if account.Type == AccountTypeServiceAccount && !account.IsOpenAI() && !account.IsCNProvider() { + if mapped, ok := account.ResolveMappedModel(model); ok { + return mapped, nil + } + return normalizeVertexAnthropicModelID(claude.NormalizeModelID(model)), nil + } + if account.IsOpenAI() && account.IsOAuth() { + return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(model)), nil + } + if !account.IsOpenAI() && !account.IsCNProvider() && !account.IsOpenCodeGo() && account.Platform != PlatformGrok && account.Type != AccountTypeAPIKey { + return model, nil + } + return account.GetMappedModel(model), nil +} + +func (p *connectionStreamState) text(value any) { + if text, ok := value.(string); ok && text != "" { + p.visible = p.visible || strings.TrimSpace(text) != "" + p.emit(TestEvent{Type: "content", Text: text}) + } +} + +func (p *connectionStreamState) complete() (bool, error) { + if !p.visible && (!p.allowMedia || !p.media) { + return false, ErrAccountTestEmpty + } + return true, nil +} + +func (p *connectionStreamState) consume(data map[string]any) (bool, error) { + if data["error"] != nil { + return false, ErrAccountTestTerminal + } + event, _ := data["type"].(string) + switch p.protocol { + case "claude": + switch event { + case "content_block_start": + block, _ := data["content_block"].(map[string]any) + p.text(block["text"]) + case "content_block_delta": + delta, _ := data["delta"].(map[string]any) + p.text(delta["text"]) + case "message_delta": + delta, _ := data["delta"].(map[string]any) + p.stopReason, _ = delta["stop_reason"].(string) + case "message_stop": + switch p.stopReason { + case "max_tokens", "model_context_window_exceeded": + p.limited = true + case "", "end_turn", "stop_sequence", "tool_use", "pause_turn", "refusal": + default: + return false, ErrAccountTestTerminal + } + return p.complete() + case "error": + return false, ErrAccountTestTerminal + } + case "gemini": + if response, ok := data["response"].(map[string]any); ok { + data = response + } + if data["error"] != nil { + return false, ErrAccountTestTerminal + } + candidates, _ := data["candidates"].([]any) + if len(candidates) == 0 { + return false, nil + } + candidate, _ := candidates[0].(map[string]any) + content, _ := candidate["content"].(map[string]any) + parts, _ := content["parts"].([]any) + for _, raw := range parts { + part, _ := raw.(map[string]any) + if thought, _ := part["thought"].(bool); thought { + continue + } + p.text(part["text"]) + inline, _ := part["inlineData"].(map[string]any) + mime, _ := inline["mimeType"].(string) + encoded, _ := inline["data"].(string) + if p.allowMedia && strings.HasPrefix(mime, "image/") && encoded != "" { + p.media = true + p.emit(TestEvent{Type: "image", ImageURL: "data:" + mime + ";base64," + encoded, MimeType: mime}) + } + } + reason, _ := candidate["finishReason"].(string) + switch reason { + case "": + return false, nil + case "MAX_TOKENS": + p.limited = true + case "STOP", "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII": + default: + return false, ErrAccountTestTerminal + } + return p.complete() + case "chat": + if event == "error" || event == "response.failed" { + return false, ErrAccountTestTerminal + } + choices, _ := data["choices"].([]any) + for _, raw := range choices { + choice, _ := raw.(map[string]any) + // The probe sends n=1. Never combine another choice's text and finish. + if index, ok := choice["index"].(float64); ok && index != 0 { + continue + } + for _, key := range []string{"delta", "message"} { + message, _ := choice[key].(map[string]any) + p.text(message["content"]) + p.text(message["refusal"]) + } + reason, _ := choice["finish_reason"].(string) + switch reason { + case "": + continue + case "length": + p.limited = true + case "stop", "content_filter", "tool_calls", "function_call": + default: + return false, ErrAccountTestTerminal + } + return p.complete() + } + case "responses": + switch event { + case "response.output_text.delta", "response.refusal.delta": + p.text(data["delta"]) + case "response.completed", "response.done", "response.incomplete": + response, _ := data["response"].(map[string]any) + status, _ := response["status"].(string) + if status == "failed" || status == "cancelled" || status == "canceled" { + return false, ErrAccountTestTerminal + } + if response["error"] != nil { + return false, ErrAccountTestTerminal + } + if !p.visible { + // Providers may send their only text in the final response object. + output, _ := response["output"].([]any) + for _, raw := range output { + item, _ := raw.(map[string]any) + parts, _ := item["content"].([]any) + for _, rawPart := range parts { + part, _ := rawPart.(map[string]any) + if part["type"] == "output_text" { + p.text(part["text"]) + } + if part["type"] == "refusal" { + p.text(part["refusal"]) + } + } + } + } + switch { + case status == "completed" && event != "response.incomplete": + case event == "response.completed" && status == "": + case (status == "incomplete" && event != "response.completed") || (event == "response.incomplete" && status == ""): + details, _ := response["incomplete_details"].(map[string]any) + if details["reason"] != "max_output_tokens" { + return false, ErrAccountTestTerminal + } + p.limited = true + default: + return false, ErrAccountTestTerminal + } + return p.complete() + case "response.failed", "response.cancelled", "error": + return false, ErrAccountTestTerminal + } + default: + return false, ErrAccountTestProtocol + } + return false, nil +} + +// One parser is used by streaming HTTP tests, background tests and the +// Antigravity adapter. EOF and [DONE] alone never certify completion. +func parseAccountConnectionStream(protocol string, body io.Reader, allowMedia bool, emit func(TestEvent), observe ...func(map[string]any)) (bool, error) { + p := connectionStreamState{protocol: protocol, allowMedia: allowMedia, emit: emit} + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 4096), 32<<20) + var lines []string + consume := func() (bool, error) { + if len(lines) == 0 { + return false, nil + } + raw := strings.Join(lines, "\n") + lines = nil + if raw == "[DONE]" { + return false, ErrAccountTestIncomplete + } + var data map[string]any + if json.Unmarshal([]byte(raw), &data) != nil { + return false, ErrAccountTestProtocol + } + for _, observer := range observe { + observer(data) + } + return p.consume(data) + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + done, err := consume() + if done || err != nil { + return p.limited, err + } + } else if strings.HasPrefix(line, "data:") { + // A few compatible providers omit blank lines between complete JSON + // events. A complete JSON value is unambiguous even in that stream. + if len(lines) > 0 && json.Valid([]byte(strings.Join(lines, "\n"))) { + done, err := consume() + if done || err != nil { + return p.limited, err + } + } + lines = append(lines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + if scanner.Err() != nil { + return false, ErrAccountTestIncomplete + } + done, err := consume() + if err != nil { + return false, err + } + if done { + return p.limited, nil + } + return false, ErrAccountTestIncomplete +} + +func (s *AccountTestService) processConnectionStream(c *gin.Context, body io.Reader, protocol string, account *Account) error { + limited, err := parseAccountConnectionStream(protocol, body, c.GetBool("account_test_allow_media"), func(event TestEvent) { s.sendEvent(c, event) }, func(data map[string]any) { + if account != nil && (data["error"] != nil || data["type"] == "response.failed" || data["type"] == "error") { + raw, _ := json.Marshal(data) + s.markCindyBalanceInsufficientFromTest(c.Request.Context(), account, http.StatusOK, raw) + } + }) + if err != nil { + s.sendEvent(c, TestEvent{Type: "error", Code: AccountTestFailureCode(err), Error: err.Error()}) + return err + } + if protocol == "chat" { + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) + } + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true, OutputLimited: limited}) + return nil +} diff --git a/backend/internal/service/account_test_stream_contract_test.go b/backend/internal/service/account_test_stream_contract_test.go new file mode 100644 index 000000000000..a17549d1b348 --- /dev/null +++ b/backend/internal/service/account_test_stream_contract_test.go @@ -0,0 +1,180 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAccountConnectionStreamContract(t *testing.T) { + frame := func(data string) string { return "data: " + data + "\n\n" } + responseText := frame(`{"type":"response.output_text.delta","delta":"OK"}`) + claudeText := frame(`{"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}`) + geminiText := frame(`{"candidates":[{"content":{"parts":[{"text":"OK"}]}}]}`) + chatText := frame(`{"choices":[{"index":0,"delta":{"content":"OK"}}]}`) + cases := []struct { + name, protocol, wire string + want error + limited bool + }{ + {"empty", "responses", "", ErrAccountTestIncomplete, false}, + {"blank_text", "responses", frame(`{"type":"response.output_text.delta","delta":" \n"}`) + frame(`{"type":"response.completed"}`), ErrAccountTestEmpty, false}, + {"responses_early_eof", "responses", responseText, ErrAccountTestIncomplete, false}, + {"responses_done_only", "responses", responseText + frame(`[DONE]`), ErrAccountTestIncomplete, false}, + {"responses_complete", "responses", responseText + frame(`{"type":"response.completed","response":{"status":"completed"}}`), nil, false}, + {"responses_done_success", "responses", responseText + frame(`{"type":"response.done","response":{"status":"completed"}}`), nil, false}, + {"responses_done_failed", "responses", responseText + frame(`{"type":"response.done","response":{"status":"failed"}}`), ErrAccountTestTerminal, false}, + {"responses_done_unknown", "responses", responseText + frame(`{"type":"response.done"}`), ErrAccountTestTerminal, false}, + {"responses_max_tokens", "responses", responseText + frame(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`), nil, true}, + {"responses_contradictory_status", "responses", responseText + frame(`{"type":"response.incomplete","response":{"status":"failed","incomplete_details":{"reason":"max_output_tokens"}}}`), ErrAccountTestTerminal, false}, + {"responses_in_progress_not_terminal", "responses", responseText + frame(`{"type":"response.incomplete","response":{"status":"in_progress","incomplete_details":{"reason":"max_output_tokens"}}}`), ErrAccountTestTerminal, false}, + {"responses_completed_not_incomplete", "responses", responseText + frame(`{"type":"response.completed","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`), ErrAccountTestTerminal, false}, + {"responses_refusal", "responses", frame(`{"type":"response.refusal.delta","delta":"I cannot help with that."}`) + frame(`{"type":"response.completed"}`), nil, false}, + {"responses_final_text", "responses", frame(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"OK"}]}]}}`), nil, false}, + {"claude_empty", "claude", frame(`{"type":"message_stop"}`), ErrAccountTestEmpty, false}, + {"claude_early_eof", "claude", claudeText, ErrAccountTestIncomplete, false}, + {"claude_done_only", "claude", claudeText + frame(`[DONE]`), ErrAccountTestIncomplete, false}, + {"claude_complete", "claude", claudeText + frame(`{"type":"message_stop"}`), nil, false}, + {"claude_limit", "claude", claudeText + frame(`{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}`) + frame(`{"type":"message_stop"}`), nil, true}, + {"gemini_empty", "gemini", frame(`{"candidates":[{"finishReason":"STOP"}]}`), ErrAccountTestEmpty, false}, + {"gemini_early_eof", "gemini", geminiText, ErrAccountTestIncomplete, false}, + {"gemini_done_only", "gemini", geminiText + frame(`[DONE]`), ErrAccountTestIncomplete, false}, + {"gemini_complete", "gemini", geminiText + frame(`{"candidates":[{"finishReason":"STOP"}]}`), nil, false}, + {"gemini_limit", "gemini", geminiText + frame(`{"candidates":[{"finishReason":"MAX_TOKENS"}]}`), nil, true}, + {"gemini_failure", "gemini", geminiText + frame(`{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL"}]}`), ErrAccountTestTerminal, false}, + {"chat_early_eof", "chat", chatText, ErrAccountTestIncomplete, false}, + {"chat_done_only", "chat", chatText + frame(`[DONE]`), ErrAccountTestIncomplete, false}, + {"chat_empty", "chat", frame(`{"choices":[{"finish_reason":"stop"}]}`), ErrAccountTestEmpty, false}, + {"chat_complete", "chat", chatText + frame(`{"choices":[{"finish_reason":"stop"}]}`), nil, false}, + {"chat_limit", "chat", chatText + frame(`{"choices":[{"finish_reason":"length"}]}`), nil, true}, + {"chat_refusal", "chat", frame(`{"choices":[{"delta":{"refusal":"I cannot help with that."},"finish_reason":"stop"}]}`), nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, background := range []bool{false, true} { + c, recorder := newTestContext() + collector := &accountTestEventCollector{} + if background { + c.Set("account_test_event_collector", collector) + } + err := (&AccountTestService{}).processConnectionStream(c, strings.NewReader(tc.wire), tc.protocol, nil) + if tc.want != nil { + require.ErrorIs(t, err, tc.want) + } else { + require.NoError(t, err) + } + if !background { + for _, line := range strings.Split(recorder.Body.String(), "\n") { + if strings.HasPrefix(line, "data: ") { + var event TestEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + collector.Add(event) + } + } + } + require.Equal(t, tc.want == nil, collector.completed) + if tc.want == nil { + require.Equal(t, tc.limited, collector.outputLimited) + require.NotEmpty(t, strings.TrimSpace(collector.text.String())) + } + } + }) + } +} + +func TestAccountConnectionTransportClassification(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &AccountTestService{} + for _, tc := range []struct { + status int + code string + }{{401, "test_authentication_failed"}, {403, "test_authentication_failed"}, {429, "test_rate_limited"}, {502, "test_upstream_failed"}} { + c, _ := newTestContext() + require.Equal(t, tc.code, AccountTestFailureCode(svc.sendAccountTestHTTPError(c, tc.status))) + } + for _, tc := range []struct { + cause error + code string + }{{context.DeadlineExceeded, "test_timeout"}, {errors.New("network-secret-material"), "test_network_failed"}} { + c, recorder := newTestContext() + err := svc.sendAccountTestRequestError(c, tc.cause) + require.Equal(t, tc.code, AccountTestFailureCode(err)) + require.NotContains(t, recorder.Body.String(), "network-secret-material") + } + for _, tc := range []struct { + failure error + code, label string + }{ + {accountTestHTTPFailure(404, accountTestEndpointAdaptiveAnthropic), "test_upstream_failed", "Adaptive Anthropic endpoint returned 404"}, + {accountTestHTTPFailure(401, accountTestEndpointAnthropic), "test_authentication_failed", "Anthropic endpoint returned 401"}, + {accountTestHTTPFailure(402, accountTestEndpointGrokResponses), "test_upstream_failed", "Grok Responses API returned 402"}, + {accountTestRequestFailure(context.DeadlineExceeded, accountTestEndpointChat), "test_timeout", "Chat Completions API (/v1/chat/completions) request failed"}, + {accountTestRequestFailure(errors.New("network-secret-material"), accountTestEndpointChat), "test_network_failed", "Chat Completions API (/v1/chat/completions) request failed"}, + } { + c, recorder := newTestContext() + err := svc.sendAccountTestFailure(c, tc.failure) + require.Equal(t, tc.code, AccountTestFailureCode(err)) + require.Contains(t, recorder.Body.String(), tc.label) + require.Contains(t, AccountTestSafeFailureMessage(err), tc.label, "batch and single adapters share the safe diagnostic") + require.NotContains(t, AccountTestSafeFailureMessage(err), "network-secret-material") + } + require.Equal(t, "account connection test failed or did not complete", AccountTestSafeFailureMessage(errors.New("network-secret-material"))) +} + +func TestAccountJobTerminalAndRetryContract(t *testing.T) { + require.Equal(t, AccountJobStatusFailed, AccountJobTerminalStatus(false, "cancel_check_failed", 1, 0, 0)) + require.Equal(t, AccountJobStatusCanceled, AccountJobTerminalStatus(true, "", 1, 1, 2)) + require.Equal(t, AccountJobStatusPartiallySucceeded, AccountJobTerminalStatus(false, "", 1, 1, 0)) + require.True(t, AccountJobHasRetryableFailures(&AccountJob{Status: AccountJobStatusCanceled, FailedCount: 1})) + require.False(t, AccountJobHasRetryableFailures(&AccountJob{Status: AccountJobStatusCanceled, CanceledCount: 2})) +} + +func TestAccountConnectionAdapterFailurePreservesProtocolCause(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &AccountTestService{} + for _, test := range []struct { + failure error + code string + }{ + {ErrAccountTestIncomplete, "test_incomplete"}, + {accountTestHTTPFailure(429), "test_rate_limited"}, + {accountTestRequestFailure(errors.New("adapter-secret-material")), "test_network_failed"}, + {errors.New("adapter-secret-material"), "test_failed"}, + } { + c, recorder := newTestContext() + forwarded := svc.sendAccountTestFailure(c, test.failure) + require.ErrorIs(t, forwarded, test.failure) + require.Equal(t, test.code, AccountTestFailureCode(forwarded)) + require.Contains(t, recorder.Body.String(), test.code) + require.NotContains(t, recorder.Body.String(), "adapter-secret-material") + } +} + +type accountTestCancelOnClose struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (r accountTestCancelOnClose) Close() error { err := r.ReadCloser.Close(); r.cancel(); return err } + +func TestAccountConnectionCompletedBeforeCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + response := adaptiveCNChatTestResponse() + response.Body = accountTestCancelOnClose{ReadCloser: response.Body, cancel: cancel} + account := openCodeGoTestAccount(490) + svc, _ := adaptiveCNAccountTestService(account, response) + result, err := svc.RunBatchTestBackgroundWithOptions(ctx, account.ID, "deepseek-v4-flash", "", AccountTestOptions{}) + require.ErrorIs(t, ctx.Err(), context.Canceled, "body close canceled immediately after the protocol terminal") + require.NoError(t, err) + require.Equal(t, "success", result.Status) + require.NotEmpty(t, strings.TrimSpace(result.ResponseText)) +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 4011b3ee524a..09023b14bf29 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -347,8 +347,9 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool // TestConnectionResult 测试连接结果 type TestConnectionResult struct { - Text string // 响应文本 - MappedModel string // 实际使用的模型 + OutputLimited bool + Text string // 响应文本 + MappedModel string // 实际使用的模型 } // TestConnection 测试 Antigravity 账号连接。 @@ -426,27 +427,35 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account // AccountSwitchError → 测试时不切换账号,返回友好提示 var switchErr *AntigravityAccountSwitchError if errors.As(err, &switchErr) { - return nil, fmt.Errorf("该账号模型 %s 当前限流中,请稍后重试", switchErr.RateLimitedModel) + return nil, accountTestHTTPFailure(http.StatusTooManyRequests) } - return nil, err + return nil, accountTestRequestFailure(err) } if result == nil || result.resp == nil { - return nil, errors.New("upstream returned empty response") + return nil, ErrAccountTestProtocol } defer func() { _ = result.resp.Body.Close() }() respBody, err := io.ReadAll(io.LimitReader(result.resp.Body, s.upstreamErrorBodyReadLimit())) if err != nil { - return nil, fmt.Errorf("读取响应失败: %w", err) + return nil, accountTestRequestFailure(err) } if result.resp.StatusCode >= 400 { - return nil, fmt.Errorf("API 返回 %d: %s", result.resp.StatusCode, string(respBody)) + return nil, accountTestHTTPFailure(result.resp.StatusCode) } - text := extractTextFromSSEResponse(respBody) - return &TestConnectionResult{Text: text, MappedModel: mappedModel}, nil + var text strings.Builder + limited, err := parseAccountConnectionStream("gemini", bytes.NewReader(respBody), false, func(event TestEvent) { + if event.Type == "content" { + _, _ = text.WriteString(event.Text) + } + }) + if err != nil { + return nil, err + } + return &TestConnectionResult{Text: text.String(), MappedModel: mappedModel, OutputLimited: limited}, nil } // testConnectionHandleError 是 TestConnection 使用的轻量 handleError 回调。 @@ -463,7 +472,7 @@ func testConnectionHandleError( } // buildGeminiTestRequest 构建 Gemini 格式测试请求 -// 使用最小 token 消耗:输入 "." + maxOutputTokens: 1 +// Keep a small visible-text budget for the connection probe. func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string, prompts ...string) ([]byte, error) { payload := map[string]any{ "contents": []map[string]any{ @@ -489,7 +498,7 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri } // buildClaudeTestRequest 构建 Claude 格式测试请求并转换为 Gemini 格式 -// 使用最小 token 消耗:输入 "." + MaxTokens: 1 +// Keep a small visible-text budget for the connection probe. func (s *AntigravityGatewayService) buildClaudeTestRequest(projectID, mappedModel string, prompts ...string) ([]byte, error) { content, err := json.Marshal(resolveAntigravityTestPrompt(prompts...)) if err != nil { @@ -519,73 +528,6 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex return opts } -// extractTextFromSSEResponse 从 SSE 流式响应中提取文本 -func extractTextFromSSEResponse(respBody []byte) string { - var texts []string - lines := bytes.Split(respBody, []byte("\n")) - - for _, line := range lines { - line = bytes.TrimSpace(line) - if len(line) == 0 { - continue - } - - // 跳过 SSE 前缀 - if bytes.HasPrefix(line, []byte("data:")) { - line = bytes.TrimPrefix(line, []byte("data:")) - line = bytes.TrimSpace(line) - } - - // 跳过非 JSON 行 - if len(line) == 0 || line[0] != '{' { - continue - } - - // 解析 JSON - var data map[string]any - if err := json.Unmarshal(line, &data); err != nil { - continue - } - - // 尝试从 response.candidates[0].content.parts[].text 提取 - response, ok := data["response"].(map[string]any) - if !ok { - // 尝试直接从 candidates 提取(某些响应格式) - response = data - } - - candidates, ok := response["candidates"].([]any) - if !ok || len(candidates) == 0 { - continue - } - - candidate, ok := candidates[0].(map[string]any) - if !ok { - continue - } - - content, ok := candidate["content"].(map[string]any) - if !ok { - continue - } - - parts, ok := content["parts"].([]any) - if !ok { - continue - } - - for _, part := range parts { - if partMap, ok := part.(map[string]any); ok { - if text, ok := partMap["text"].(string); ok && text != "" { - texts = append(texts, text) - } - } - } - } - - return strings.Join(texts, "") -} - // injectIdentityPatchToGeminiRequest 为 Gemini 格式请求注入身份提示词 // 如果请求中已包含 "You are Antigravity" 则不重复注入 func injectIdentityPatchToGeminiRequest(body []byte) ([]byte, error) { diff --git a/backend/internal/service/business_system_prompt_service.go b/backend/internal/service/business_system_prompt_service.go index 7086a7ea70d6..2c3ff1933cd5 100644 --- a/backend/internal/service/business_system_prompt_service.go +++ b/backend/internal/service/business_system_prompt_service.go @@ -156,6 +156,7 @@ type BusinessSystemPromptService struct { bus BusinessSystemPromptRevisionBus registry *RemoteSkillRegistryService registryBus RemoteSkillRegistryRevisionBus + frozenFiles *FrozenPromptFiles source BusinessSystemPromptSource snapshot atomic.Pointer[BusinessSystemPromptSnapshot] @@ -227,6 +228,17 @@ func (s *BusinessSystemPromptService) Initialize(ctx context.Context) error { if s == nil || s.store == nil { return errors.New("business system prompt store unavailable") } + if s.frozenFiles != nil { + if err := s.frozenFiles.Initialize(ctx); err != nil { + return err + } + // The provider performs migration and the first load synchronously. + // Domain startup only needs to attach the revision subscriber afterward. + if s.snapshot.Load() != nil { + return nil + } + return s.Reload(ctx) + } var seeds []BusinessSystemPromptSeed if err := invokePromptManagement(ctx, "prompt.seeds", struct{}{}, &seeds); err != nil { return err diff --git a/backend/internal/service/codex_routing_proxy.go b/backend/internal/service/codex_routing_proxy.go index d1c948acd4e1..48c4cb565ad1 100644 --- a/backend/internal/service/codex_routing_proxy.go +++ b/backend/internal/service/codex_routing_proxy.go @@ -6,7 +6,6 @@ import ( "encoding/json" "net" "net/http" - "net/url" "time" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" @@ -24,10 +23,11 @@ func (s *OpenAIGatewayService) doCodexRoutingAcquisition(request *http.Request, if request == nil || request.URL == nil || request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "chatgpt.com" || request.URL.Path != "/backend-api/codex/responses" || request.URL.RawQuery != "" || request.URL.User != nil || s.nativeCodexRuntime == nil { return nil, errCodexRoutingUnavailable } - normal, err := proxytransport.Normalize(proxyURL) - if err != nil || normal == "" { + _, err := proxytransport.ParseEndpoint(proxyURL) + if err != nil { return nil, errCodexRoutingUnavailable } + normal := proxyURL installation := s.nativeCodexRuntime.metadata() store := s.nativeCodexRuntime.repo ok := store != nil @@ -102,7 +102,7 @@ func verifyCodexRoutingProxyCertificate(state tls.ConnectionState, pinnedRoots * } func codexRoutingAcquisitionTransport(raw string, pinnedRoots *x509.CertPool) (*http.Transport, error) { - address, err := url.Parse(raw) + address, err := proxytransport.ParseEndpoint(raw) if err != nil { return nil, errCodexRoutingUnavailable } diff --git a/backend/internal/service/codex_ticket_proxy_test.go b/backend/internal/service/codex_ticket_proxy_test.go index a9b5f8346149..640db77beb86 100644 --- a/backend/internal/service/codex_ticket_proxy_test.go +++ b/backend/internal/service/codex_ticket_proxy_test.go @@ -14,6 +14,7 @@ func TestCodexTicketProxyInput(t *testing.T) { {"user:pass@proxy.example.com:8080", "http://user:pass@proxy.example.com:8080"}, {"socks5h://u:p@proxy.example.com:1080", "socks5h://u:p@proxy.example.com:1080"}, {"[::1]:8080", "http://[::1]:8080"}, + {"user:pass:host:1234", "http://user:pass@host:1234"}, } { t.Run(tc.input, func(t *testing.T) { got, err := NormalizeCodexTicketProxy(tc.input) @@ -21,7 +22,7 @@ func TestCodexTicketProxyInput(t *testing.T) { require.Equal(t, tc.want, got) }) } - for _, input := range []string{"host: x\nport: 8080\nport: 90", "host: x\nport: 8080\nusername: u", "username: x\npassword: y", "user:pass:host:1234", "http://x:70000", "ftp://x:21"} { + for _, input := range []string{"host: x\nport: 8080\nport: 90", "host: x\nport: 8080\nusername: u", "username: x\npassword: y", "http://x:70000", "ftp://x:21"} { _, err := NormalizeCodexTicketProxy(input) require.Error(t, err) } diff --git a/backend/internal/service/prompt_config.go b/backend/internal/service/prompt_config.go index 6e22677f8e22..0471745740ce 100644 --- a/backend/internal/service/prompt_config.go +++ b/backend/internal/service/prompt_config.go @@ -13,7 +13,8 @@ import ( // PromptContentDraft exists only in an edit/preview request. Published content // remains an immutable version referenced by its rule. type PromptContentDraft struct { - Body string `json:"body"` + Body string `json:"body"` + RestoreVersionID int64 `json:"restore_version_id,omitempty"` } type PromptConfigUpdate struct { @@ -23,6 +24,28 @@ type PromptConfigUpdate struct { CompactEnabled bool `json:"compact_enabled"` Policy extensionv1.PromptRulePolicy `json:"policy"` Contents map[string]PromptContentDraft `json:"contents"` + preserveExpose bool + preserveCompact bool +} + +// The core editor does not expose compatibility switches. Omitted/null fields +// preserve their current values, while legacy callers can still send a bool. +func (input *PromptConfigUpdate) UnmarshalJSON(raw []byte) error { + type configUpdate PromptConfigUpdate + var decoded configUpdate + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + var compatibility struct { + Expose *bool `json:"expose_server_prompt"` + Compact *bool `json:"compact_enabled"` + } + if err := json.Unmarshal(raw, &compatibility); err != nil { + return err + } + *input = PromptConfigUpdate(decoded) + input.preserveExpose, input.preserveCompact = compatibility.Expose == nil, compatibility.Compact == nil + return nil } type PromptConfigContent struct { @@ -120,7 +143,7 @@ func (s *BusinessSystemPromptService) PromptConfig(ctx context.Context) (PromptC } state.Contents[rule.ID] = PromptConfigContent{ Body: body, CompositionMode: version.CompositionMode, - Managed: detail.Template.ManagedSource != "" || version.CompositionMode != BusinessSystemPromptCompositionInline, + Managed: detail.Template.ManagedSource != "" || version.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid, TemplateID: rule.TemplateID, VersionID: rule.VersionID, Available: available, } } @@ -139,6 +162,25 @@ func (s *BusinessSystemPromptService) SavePromptConfig(ctx context.Context, inpu if input.ExpectedRevision < 1 || current.Revision != input.ExpectedRevision { return PromptConfigState{}, ErrBusinessSystemPromptRevisionConflict } + if input.preserveExpose { + input.ExposeServerPrompt = current.ExposeServerPrompt + } + if input.preserveCompact { + input.CompactEnabled = current.CompactEnabled + } + for _, rule := range input.Policy.Rules { + oldIndex := slices.IndexFunc(current.RulePolicy.Rules, func(previous extensionv1.PromptRule) bool { return previous.ID == rule.ID }) + if oldIndex < 0 { + if rule.TemplateID != 0 || rule.VersionID != 0 { + return PromptConfigState{}, ErrBusinessSystemPromptInvalid + } + if _, ok := input.Contents[rule.ID]; !ok { + return PromptConfigState{}, ErrBusinessSystemPromptInvalid + } + } else if rule.TemplateID != current.RulePolicy.Rules[oldIndex].TemplateID { + return PromptConfigState{}, ErrBusinessSystemPromptInvalid + } + } current.Enabled, current.ExposeServerPrompt, current.CompactEnabled = input.Enabled, input.ExposeServerPrompt, input.CompactEnabled current.RulePolicy = &input.Policy if err := s.preparePromptRulesDraft(ctx, ¤t, input.Contents); err != nil { @@ -195,7 +237,9 @@ func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, sn } for _, rule := range policy.Rules { var content BusinessSystemPromptSnapshot + preserveEcho := false if draft, edited := drafts[rule.ID]; edited { + mode := BusinessSystemPromptCompositionInline if rule.TemplateID > 0 { detail, err := s.store.GetBusinessSystemPromptTemplate(ctx, rule.TemplateID) if err != nil { @@ -205,15 +249,46 @@ func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, sn if index < 0 { return ErrBusinessSystemPromptVersionNotFound } - if detail.Template.ManagedSource != "" || detail.Versions[index].CompositionMode != BusinessSystemPromptCompositionInline { + mode = detail.Versions[index].CompositionMode + if detail.Template.ManagedSource != "" || mode == BusinessSystemPromptCompositionCodexSkillHybrid { return ErrBusinessSystemPromptSourceNotManaged } + if draft.RestoreVersionID > 0 { + versions, err := s.PromptRuleHistory(ctx, rule.ID) + if err != nil { + return err + } + historyIndex := slices.IndexFunc(versions, func(version PromptHistoryVersion) bool { + return version.ID == draft.RestoreVersionID && version.Restorable && version.CompositionMode == mode + }) + if historyIndex < 0 { + return ErrBusinessSystemPromptVersionNotFound + } + if mode == extensionv1.PromptContentAnthropicSystemBlocks { + if err := ValidateStructuredPromptTextEdit(versions[historyIndex].Body, draft.Body); err != nil { + return err + } + } + if store, ok := s.store.(IndependentPromptStore); ok { + historicalEcho, err := store.PromptVersionPreserveEcho(ctx, draft.RestoreVersionID) + if err != nil { + return err + } + preserveEcho = PreserveRestoredPromptEcho(versions[historyIndex].Body, draft.Body, historicalEcho) + } + } else if mode == extensionv1.PromptContentAnthropicSystemBlocks { + if err := ValidateStructuredPromptTextEdit(detail.Versions[index].Body, draft.Body); err != nil { + return err + } + } + } else if draft.RestoreVersionID > 0 { + return ErrBusinessSystemPromptVersionNotFound } hash, size, err := ValidateBusinessSystemPromptBody(draft.Body) if err != nil { return err } - content = BusinessSystemPromptSnapshot{Enabled: true, Body: draft.Body, SHA256: hash, ByteLength: size, CompositionMode: BusinessSystemPromptCompositionInline} + content = BusinessSystemPromptSnapshot{Enabled: true, Body: draft.Body, SHA256: hash, ByteLength: size, CompositionMode: mode} } else { detail, err := s.store.GetBusinessSystemPromptTemplate(ctx, rule.TemplateID) if err != nil { @@ -224,6 +299,13 @@ func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, sn return ErrBusinessSystemPromptVersionNotFound } version := detail.Versions[index] + if store, ok := s.store.(IndependentPromptStore); ok { + var err error + preserveEcho, err = store.PromptVersionPreserveEcho(ctx, version.ID) + if err != nil { + return err + } + } content = BusinessSystemPromptSnapshot{Enabled: true, Revision: snapshot.Revision, TemplateID: rule.TemplateID, VersionID: rule.VersionID, Body: version.Body, SHA256: version.SHA256, ByteLength: version.ByteLength, CompositionMode: version.CompositionMode, BundleID: version.BundleID, BundleManifestSHA256: version.BundleManifestSHA256} if content.CompositionMode == extensionv1.PromptContentAnthropicSystemBlocks && !validStructuredPromptScope(rule) { return fmt.Errorf("%w: structured Claude OAuth sources require their native control carrier and request profile", ErrPromptDeliveryUnsupported) @@ -263,6 +345,14 @@ func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, sn } } } + if content.CompositionMode == extensionv1.PromptContentAnthropicSystemBlocks { + if !validStructuredPromptScope(rule) { + return ErrPromptDeliveryUnsupported + } + if _, err := parseClaudeOAuthSystemPromptBlocksConfig(content.Body); err != nil { + return fmt.Errorf("%w: invalid system blocks", ErrBusinessSystemPromptInvalid) + } + } hash, _, err := validateBusinessSystemPromptBodyWithLimit(content.Body, extensionv1.PromptRulesMaxBytes) if err != nil { return err @@ -270,7 +360,7 @@ func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, sn if !rule.Enabled { continue } - resolved := extensionv1.ResolvedPromptRule{Rule: rule, Body: content.Body, SHA256: hash, PreserveEcho: content.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid} + resolved := extensionv1.ResolvedPromptRule{Rule: rule, Body: content.Body, SHA256: hash, PreserveEcho: preserveEcho || content.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid} if content.CompositionMode == extensionv1.PromptContentAnthropicSystemBlocks { resolved.ContentFormat = content.CompositionMode resolved.StructuredContent = json.RawMessage(content.Body) diff --git a/backend/internal/service/prompt_config_test.go b/backend/internal/service/prompt_config_test.go index f41041a12760..07691824aa13 100644 --- a/backend/internal/service/prompt_config_test.go +++ b/backend/internal/service/prompt_config_test.go @@ -102,7 +102,7 @@ func TestPromptConfigDraftAndRejectedEditsDoNotWrite(t *testing.T) { func TestPromptConfigInlineRuntimeStartsWithoutSkillRegistry(t *testing.T) { svc, _, _, _ := newPromptConfigService(t) svc.bus = nil - runtime := NewPromptDomainRuntime(nil, svc) + runtime := NewPromptDomainRuntime(svc) runtime.Start(context.Background()) t.Cleanup(runtime.Stop) require.True(t, runtime.ready) diff --git a/backend/internal/service/prompt_domain_runtime.go b/backend/internal/service/prompt_domain_runtime.go index 84cb5322b2bd..d6a0ae36d8ce 100644 --- a/backend/internal/service/prompt_domain_runtime.go +++ b/backend/internal/service/prompt_domain_runtime.go @@ -10,23 +10,20 @@ import ( extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -// Inline prompt serving is independent of the optional paired Skill source. -// Each service starts once; an unavailable registry is retried independently. +// Core prompt serving starts after its atomic content migration completes. type PromptDomainRuntime struct { - registry *RemoteSkillRegistryService - prompts *BusinessSystemPromptService - mu sync.Mutex - reconcileMu sync.Mutex - cancel context.CancelFunc - done chan struct{} - ready bool - promptsReady bool - registryReady bool - nextRetry time.Time + prompts *BusinessSystemPromptService + mu sync.Mutex + reconcileMu sync.Mutex + cancel context.CancelFunc + done chan struct{} + ready bool + promptsReady bool + nextRetry time.Time } -func NewPromptDomainRuntime(registry *RemoteSkillRegistryService, prompts *BusinessSystemPromptService) *PromptDomainRuntime { - return &PromptDomainRuntime{registry: registry, prompts: prompts} +func NewPromptDomainRuntime(prompts *BusinessSystemPromptService) *PromptDomainRuntime { + return &PromptDomainRuntime{prompts: prompts} } func promptPolicyAvailability(ctx context.Context) error { @@ -91,18 +88,6 @@ func (r *PromptDomainRuntime) reconcile(ctx context.Context, now time.Time) { r.mu.Unlock() return } - if !r.registryReady { - if r.registry == nil { - r.registryReady = true - } else if err := r.registry.Start(ctx); err != nil { - slog.Warn("prompt_domain_initialization_unavailable", "component", "registry") - } else { - r.registryReady = true - if r.promptsReady { - _ = r.prompts.Reload(ctx) - } - } - } if !r.promptsReady { if err := r.prompts.Start(ctx); err != nil { slog.Warn("prompt_domain_initialization_unavailable", "component", "prompts") @@ -111,7 +96,7 @@ func (r *PromptDomainRuntime) reconcile(ctx context.Context, now time.Time) { } } r.mu.Lock() - r.ready = r.promptsReady && r.registryReady + r.ready = r.promptsReady if r.ready { r.nextRetry = time.Time{} } else { @@ -124,17 +109,10 @@ func (r *PromptDomainRuntime) stopDomainServices() { if r == nil { return } - // Stop the prompt subscriber before the registry so no late prompt reload - // races with registry shutdown. Both services retain their snapshots and - // durable records. if r.prompts != nil { r.prompts.Stop() } - if r.registry != nil { - r.registry.Stop() - } r.promptsReady = false - r.registryReady = false } func (r *PromptDomainRuntime) Stop() { diff --git a/backend/internal/service/prompt_domain_runtime_test.go b/backend/internal/service/prompt_domain_runtime_test.go index b0a570389794..ba23f30b798b 100644 --- a/backend/internal/service/prompt_domain_runtime_test.go +++ b/backend/internal/service/prompt_domain_runtime_test.go @@ -31,10 +31,9 @@ func TestPromptDomainHealthyStartReloadsPromptOnce(t *testing.T) { invokePromptSkills = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { return (promptPolicyFixture{}).InvokeOperation(ctx, "", "", in) } - registry, _, _ := testRemoteSkillRegistry(t, testRemoteSkillCandidate(t, 1, 1, "seed")) store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Body: embeddedBusinessSystemPrompt}} prompts := NewBusinessSystemPromptService(store, nil) - runtime := NewPromptDomainRuntime(registry, prompts) + runtime := NewPromptDomainRuntime(prompts) runtime.Start(context.Background()) require.True(t, runtime.ready) runtime.Stop() diff --git a/backend/internal/service/prompt_independent.go b/backend/internal/service/prompt_independent.go new file mode 100644 index 000000000000..843e76541455 --- /dev/null +++ b/backend/internal/service/prompt_independent.go @@ -0,0 +1,172 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "sync" + "time" +) + +// IndependentPromptStore is the one-time upgrade and read-only compatibility +// boundary. It never installs seeds or follows a registry's active version. +type IndependentPromptStore interface { + InitializeIndependentPrompts(context.Context, RemoteSkillRegistryFiles) (map[string][]byte, error) + PromptVersionPreserveEcho(context.Context, int64) (bool, error) + PromptRuleHistory(context.Context, string) ([]PromptHistoryVersion, error) +} + +type PromptHistoryVersion struct { + ID int64 `json:"id"` + Body string `json:"body"` + CompositionMode string `json:"composition_mode"` + CreatedAt time.Time `json:"created_at"` + Restorable bool `json:"restorable"` +} + +type FrozenPromptFiles struct { + store IndependentPromptStore + source RemoteSkillRegistryFiles + mu sync.RWMutex + files map[string][]byte +} + +func ProvideFrozenPromptFiles(store BusinessSystemPromptStore, files RemoteSkillRegistryFiles) *FrozenPromptFiles { + independent, _ := store.(IndependentPromptStore) + return &FrozenPromptFiles{store: independent, source: files} +} + +func (f *FrozenPromptFiles) Initialize(ctx context.Context) error { + if f == nil || f.store == nil { + return ErrBusinessSystemPromptUnavailable + } + f.mu.Lock() + defer f.mu.Unlock() + if f.files != nil { + return nil + } + files, err := f.store.InitializeIndependentPrompts(ctx, f.source) + if err != nil { + return err + } + f.files = cloneRemoteSkillFiles(files) + return nil +} + +func (f *FrozenPromptFiles) LoadPublishedFile(ctx context.Context, name string) (RemoteSkillPublicFile, error) { + if err := ctx.Err(); err != nil { + return RemoteSkillPublicFile{}, err + } + normalized, err := normalizeBundleRelativePath(name) + if err != nil || normalized != name || strings.ContainsAny(name, "?#") || strings.HasSuffix(name, "/") { + return RemoteSkillPublicFile{}, ErrRemoteSkillPublicFileNotFound + } + if f == nil { + return RemoteSkillPublicFile{}, ErrBusinessSystemPromptUnavailable + } + f.mu.RLock() + defer f.mu.RUnlock() + if f.files == nil { + return RemoteSkillPublicFile{}, ErrBusinessSystemPromptUnavailable + } + body, ok := f.files[name] + if !ok { + return RemoteSkillPublicFile{}, ErrRemoteSkillPublicFileNotFound + } + return RemoteSkillPublicFile{Body: bytes.Clone(body), ETag: `"` + hashBusinessSystemPromptBundleBytes(body) + `"`, ContentType: remoteSkillContentType(name)}, nil +} + +// ValidateFrozenSkillPublication uses the same paired-generation validator as +// ActivePublication. Only the effective body and complete effective tree leave +// this boundary; there is deliberately no seed fallback or historical rollback. +func ValidateFrozenSkillPublication(revision int64, candidate RemoteSkillCandidate) (RemoteSkillPublication, error) { + if err := validateStoredPairedRemoteSkillCandidate(candidate, true); err != nil { + return RemoteSkillPublication{}, err + } + return remoteSkillPublicationFromCandidate(revision, candidate) +} + +func (s *BusinessSystemPromptService) SetFrozenPromptFiles(files *FrozenPromptFiles) { + s.frozenFiles = files +} + +// Only an exact content restoration inherits compatibility. Typing into the +// restored draft makes it an ordinary private edit, just like any new version. +func PreserveRestoredPromptEcho(historicalBody, draftBody string, historicalEcho bool) bool { + return historicalEcho && historicalBody == draftBody +} + +func (s *BusinessSystemPromptService) PromptRuleHistory(ctx context.Context, ruleID string) ([]PromptHistoryVersion, error) { + snapshot, ok := s.CurrentSnapshot() + if !ok || snapshot.RulePolicy == nil { + return nil, ErrBusinessSystemPromptUnavailable + } + found := false + for _, rule := range snapshot.RulePolicy.Rules { + if rule.ID == ruleID { + found = true + break + } + } + if !found { + return nil, ErrBusinessSystemPromptVersionNotFound + } + store, ok := s.store.(IndependentPromptStore) + if !ok { + return nil, ErrBusinessSystemPromptUnavailable + } + return store.PromptRuleHistory(ctx, ruleID) +} + +// Editing structured content may change text only. Restore is separately +// authorized against the current rule's history before this check is bypassed. +func ValidateStructuredPromptTextEdit(previous, next string) error { + if err := ValidateClaudeOAuthSystemPromptBlocksConfig(next); err != nil { + return fmt.Errorf("%w: invalid structured prompt", ErrBusinessSystemPromptInvalid) + } + var before, after any + if json.Unmarshal([]byte(previous), &before) != nil || json.Unmarshal([]byte(next), &after) != nil { + return ErrBusinessSystemPromptInvalid + } + stripText := func(value any) bool { + var blocks []any + switch document := value.(type) { + case map[string]any: + if _, ok := document["expansion_prompt"]; ok { + document["expansion_prompt"] = "" + } + if document["blocks"] != nil { + var ok bool + blocks, ok = document["blocks"].([]any) + if !ok { + return false + } + } + case []any: + blocks = document + default: + return false + } + for _, raw := range blocks { + if raw == nil { + continue + } + block, ok := raw.(map[string]any) + if !ok { + return false + } + if _, ok := block["text"].(string); block["text"] != nil && !ok { + return false + } + block["text"] = "" + } + return true + } + if !stripText(before) || !stripText(after) || !reflect.DeepEqual(before, after) { + return fmt.Errorf("%w: structured prompt metadata is immutable", ErrBusinessSystemPromptInvalid) + } + return nil +} diff --git a/backend/internal/service/prompt_independent_test.go b/backend/internal/service/prompt_independent_test.go new file mode 100644 index 000000000000..ea66ae81aab7 --- /dev/null +++ b/backend/internal/service/prompt_independent_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" +) + +type promptCoreInitializationStore struct { + promptConfigMemoryStore + initializations int +} + +func (s *promptCoreInitializationStore) InitializeIndependentPrompts(context.Context, RemoteSkillRegistryFiles) (map[string][]byte, error) { + s.initializations++ + return map[string][]byte{}, nil +} +func (*promptCoreInitializationStore) PromptVersionPreserveEcho(context.Context, int64) (bool, error) { + return false, nil +} +func (*promptCoreInitializationStore) PromptRuleHistory(context.Context, string) ([]PromptHistoryVersion, error) { + return nil, nil +} + +func TestPromptCoreProviderInitializationIsNotRepeatedByDomainStart(t *testing.T) { + store := &promptCoreInitializationStore{} + store.loaded = BusinessSystemPromptSnapshot{Revision: 7, RulePolicy: &nativeapi.PromptRulePolicy{Version: 2, Rules: []nativeapi.PromptRule{}, DefaultRuleIDs: []string{}}} + svc := NewBusinessSystemPromptService(store, nil) + svc.SetFrozenPromptFiles(ProvideFrozenPromptFiles(store, nil)) + require.NoError(t, svc.Initialize(context.Background())) + runtime := NewPromptDomainRuntime(svc) + runtime.Start(context.Background()) + t.Cleanup(runtime.Stop) + require.True(t, runtime.ready) + require.Equal(t, 1, store.initializations) + require.Equal(t, 1, store.loadCalls) +} + +func TestPromptCoreOmittedCompatibilityFlagsArePreserved(t *testing.T) { + svc, store, _, input := newPromptConfigService(t) + store.loaded.CompactEnabled, store.loaded.ExposeServerPrompt = true, true + require.NoError(t, svc.Reload(context.Background())) + raw, err := json.Marshal(input) + require.NoError(t, err) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &payload)) + delete(payload, "compact_enabled") + delete(payload, "expose_server_prompt") + raw, err = json.Marshal(payload) + require.NoError(t, err) + var decoded PromptConfigUpdate + require.NoError(t, json.Unmarshal(raw, &decoded)) + state, err := svc.SavePromptConfig(context.Background(), decoded, 7) + require.NoError(t, err) + require.True(t, state.CompactEnabled) + require.True(t, state.ExposeServerPrompt) + require.True(t, store.last.CompactEnabled && store.last.ExposeServerPrompt) +} + +func TestPromptCoreRejectsRebindingNewRulesToOldSources(t *testing.T) { + svc, store, _, input := newPromptConfigService(t) + input.Policy.Rules[0].TemplateID, input.Policy.Rules[0].VersionID = 42, 43 + _, err := svc.SavePromptConfig(context.Background(), input, 7) + require.ErrorIs(t, err, ErrBusinessSystemPromptInvalid) + require.Zero(t, store.saves) +} + +func TestPromptCoreStructuredTextEditsPreserveMetadata(t *testing.T) { + body := `{"blocks":[{"type":"text","text":"first","enabled":false},{"type":"text","text":"second","cache_control":{"type":"ephemeral","ttl":"1h","custom":"keep"}}],"expansion_prompt":"literal {fp}"}` + require.NoError(t, ValidateStructuredPromptTextEdit(body, strings.Replace(body, "second", "new second", 1))) + require.NoError(t, ValidateStructuredPromptTextEdit(body, strings.Replace(body, "literal {fp}", "new expansion", 1))) + array := `[{"type":"text","text":"array text","cache_control":{"type":"ephemeral","ttl":"1h"}},null,{"enabled":false}]` + require.NoError(t, ValidateStructuredPromptTextEdit(array, strings.Replace(array, "array text", "edited array", 1))) + for _, changed := range []string{strings.Replace(body, `"enabled":false`, `"enabled":true`, 1), strings.Replace(body, `"ttl":"1h"`, `"ttl":"5m"`, 1), `{"blocks":[]}`} { + require.ErrorIs(t, ValidateStructuredPromptTextEdit(body, changed), ErrBusinessSystemPromptInvalid) + } +} + +func TestPromptCoreFrozenReaderDoesNotFollowSourceOrExposeMutableBytes(t *testing.T) { + files := &FrozenPromptFiles{files: map[string][]byte{"SKILL.md": []byte("frozen publication"), "scripts/tool.py": []byte("print('frozen')")}} + first, err := files.LoadPublishedFile(context.Background(), "SKILL.md") + require.NoError(t, err) + first.Body[0] = 'X' + next, err := files.LoadPublishedFile(context.Background(), "SKILL.md") + require.NoError(t, err) + require.Equal(t, "frozen publication", string(next.Body)) + require.Equal(t, first.ETag, next.ETag) + for _, path := range []string{"../SKILL.md", "scripts/../SKILL.md", "SKILL.md?x", "missing.md"} { + _, err := files.LoadPublishedFile(context.Background(), path) + require.ErrorIs(t, err, ErrRemoteSkillPublicFileNotFound) + } + require.True(t, PreserveRestoredPromptEcho("public", "public", true)) + require.False(t, PreserveRestoredPromptEcho("public", "edited public", true)) + require.False(t, PreserveRestoredPromptEcho("private", "private", false)) +} diff --git a/backend/internal/service/prompt_provider_init_test.go b/backend/internal/service/prompt_provider_init_test.go new file mode 100644 index 000000000000..009194e0c20a --- /dev/null +++ b/backend/internal/service/prompt_provider_init_test.go @@ -0,0 +1,30 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +type unavailableIndependentPromptStore struct { + BusinessSystemPromptStore + IndependentPromptStore + err error +} + +func (s *unavailableIndependentPromptStore) InitializeIndependentPrompts(context.Context, RemoteSkillRegistryFiles) (map[string][]byte, error) { + return nil, s.err +} + +func TestPromptProviderRejectsIncompleteMigrationBeforeServing(t *testing.T) { + failure := errors.New("active publication is incomplete") + store := &unavailableIndependentPromptStore{err: failure} + frozen := ProvideFrozenPromptFiles(store, nil) + prompts, err := ProvideBusinessSystemPromptService(store, nil, frozen, nil, nil, nil) + require.ErrorIs(t, err, failure) + require.Nil(t, prompts) + _, err = frozen.LoadPublishedFile(context.Background(), "SKILL.md") + require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index eb6ce38e9c4e..74b267d49a2e 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -68,8 +69,13 @@ func notifyPersistedAccountSchedulingCooldown(blocker AccountRuntimeBlocker, acc // SuccessfulTestRecoveryResult 表示测试成功后恢复了哪些运行时状态。 type SuccessfulTestRecoveryResult struct { - ClearedError bool - ClearedRateLimit bool + ClearedError bool + ClearedRateLimit bool + ManualStatePreserved bool +} + +type SuccessfulTestRecoveryRepository interface { + RecoverAfterSuccessfulTest(context.Context, int64) (*SuccessfulTestRecoveryResult, error) } // AccountRecoveryOptions 控制账号恢复时的附加行为。 @@ -2235,7 +2241,27 @@ func (s *RateLimitService) RecoverAccountState(ctx context.Context, accountID in // RecoverAccountAfterSuccessfulTest 将一次成功测试视为正常请求, // 按需恢复 error / rate-limit / overload / temp-unsched / model-rate-limit 等运行时状态。 func (s *RateLimitService) RecoverAccountAfterSuccessfulTest(ctx context.Context, accountID int64) (*SuccessfulTestRecoveryResult, error) { - return s.RecoverAccountState(ctx, accountID, AccountRecoveryOptions{}) + if s == nil || s.accountRepo == nil { + return nil, errors.New("account recovery is unavailable") + } + repo, ok := s.accountRepo.(SuccessfulTestRecoveryRepository) + if !ok { + return nil, errors.New("atomic account recovery is unavailable") + } + result, err := repo.RecoverAfterSuccessfulTest(ctx, accountID) + if err != nil || result == nil { + return result, err + } + if result.ClearedRateLimit && s.tempUnschedCache != nil { + if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil { + return result, err + } + } + if result.ClearedError || result.ClearedRateLimit { + s.ResetOpenAI403Counter(ctx, accountID) + s.notifyAccountSchedulingBlockCleared(accountID) + } + return result, nil } func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID int64) error { diff --git a/backend/internal/service/ratelimit_service_clear_test.go b/backend/internal/service/ratelimit_service_clear_test.go index e8d312f9b98e..1c36260eeff5 100644 --- a/backend/internal/service/ratelimit_service_clear_test.go +++ b/backend/internal/service/ratelimit_service_clear_test.go @@ -29,6 +29,34 @@ type rateLimitClearRepoStub struct { clearTempUnschedulableErr error } +func (r *rateLimitClearRepoStub) RecoverAfterSuccessfulTest(ctx context.Context, id int64) (*SuccessfulTestRecoveryResult, error) { + a, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + result := &SuccessfulTestRecoveryResult{} + if a.Status != StatusActive && a.Status != StatusError { + result.ManualStatePreserved = true + return result, nil + } + if a.Status == StatusError { + if err := r.ClearError(ctx, id); err != nil { + return nil, err + } + result.ClearedError = true + } + if hasRecoverableRuntimeState(a) { + for _, clear := range []func(context.Context, int64) error{r.ClearRateLimit, r.ClearAntigravityQuotaScopes, r.ClearModelRateLimits, r.ClearTempUnschedulable} { + if err := clear(ctx, id); err != nil { + return nil, err + } + } + result.ClearedRateLimit = true + } + result.ManualStatePreserved = !a.Schedulable + return result, nil +} + func (r *rateLimitClearRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) { r.getByIDCalls++ if r.getByIDErr != nil { diff --git a/backend/internal/service/scheduled_test_port.go b/backend/internal/service/scheduled_test_port.go index 921f064ecd77..8811b5620b5c 100644 --- a/backend/internal/service/scheduled_test_port.go +++ b/backend/internal/service/scheduled_test_port.go @@ -22,6 +22,8 @@ type ScheduledTestPlan struct { // ScheduledTestResult represents a single test execution result. type ScheduledTestResult struct { + OutputLimited bool `json:"output_limited,omitempty"` + ActualModel string `json:"actual_model,omitempty"` RequestedReasoningEffort string `json:"requested_reasoning_effort,omitempty"` EffectiveReasoningEffort string `json:"effective_reasoning_effort,omitempty"` ID int64 `json:"id"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 912a45677ca7..9d63c105a3ab 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -49,13 +49,12 @@ func ProvideUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, b return NewUpdateService(cache, githubClient, buildInfo.Version, buildInfo.BuildType) } -// PromptDomainRuntime initializes storage after the policy process is ready. -// Construction does not execute plugin-dependent operations. +// The prompt domain migrates existing content once and serves independent +// versions with a frozen public file tree; it has no live source registry. func ProvideBusinessSystemPromptService( store BusinessSystemPromptStore, bus BusinessSystemPromptRevisionBus, - remoteSkillRegistry *RemoteSkillRegistryService, - remoteSkillRegistryBus RemoteSkillRegistryRevisionBus, + frozen *FrozenPromptFiles, accountRepo AccountRepository, cfg *config.Config, settings *SettingService, @@ -64,8 +63,14 @@ func ProvideBusinessSystemPromptService( svc.SetAccountRepository(accountRepo) svc.previewConfig = cfg svc.previewSettings = settings - svc.SetRemoteSkillRegistryService(remoteSkillRegistry) - svc.SetRemoteSkillRegistryRevisionBus(remoteSkillRegistryBus) + svc.SetFrozenPromptFiles(frozen) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + // Content migration is part of application initialization. Do not start a + // healthy HTTP server with missing effective prompts or public source files. + if err := svc.Initialize(ctx); err != nil { + return nil, err + } return svc, nil } @@ -73,20 +78,6 @@ func ProvideRemoteSkillRegistryFiles() RemoteSkillRegistryFiles { return NewRemoteSkillRegistryFilesystem("") } -func ProvideRemoteSkillCandidateSource() RemoteSkillCandidateSource { - return NewMoxinggangRemoteSkillCandidateSource(nil) -} - -func ProvideRemoteSkillRegistryService( - store RemoteSkillRegistryStore, - bus RemoteSkillRegistryRevisionBus, - files RemoteSkillRegistryFiles, - source RemoteSkillCandidateSource, -) (*RemoteSkillRegistryService, error) { - svc := NewRemoteSkillRegistryService(store, bus, files, source) - return svc, nil -} - // ProvideOpenAIGatewayService keeps the existing constructor signature used by // tests while wiring the optional global business prompt policy into the // production gateway instance. @@ -1086,8 +1077,7 @@ var ProviderSet = wire.NewSet( ProvideGatewayService, ProvideBusinessSystemPromptService, ProvideRemoteSkillRegistryFiles, - ProvideRemoteSkillCandidateSource, - ProvideRemoteSkillRegistryService, + ProvideFrozenPromptFiles, ProvideOpenAIGatewayService, ProvideCindyHealthService, ProvideCindyBalanceProbeService, diff --git a/backend/migrations/256_prompt_independent_content.sql b/backend/migrations/256_prompt_independent_content.sql new file mode 100644 index 000000000000..ac308a4892c0 --- /dev/null +++ b/backend/migrations/256_prompt_independent_content.sql @@ -0,0 +1,45 @@ +-- Storage only. The application validates the active on-disk publication and +-- migrates content plus its complete public tree in one database transaction. +-- No embedded seed or archived hybrid body may substitute for that publication. +CREATE TABLE IF NOT EXISTS system_prompt_independent_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS system_prompt_independent_contents ( + template_id BIGINT PRIMARY KEY REFERENCES system_prompt_templates(id), + rule_id TEXT NOT NULL UNIQUE, + source_template_id BIGINT NOT NULL REFERENCES system_prompt_templates(id), + baseline_version_id BIGINT NOT NULL REFERENCES system_prompt_template_versions(id) +); + +CREATE TABLE IF NOT EXISTS system_prompt_version_compat ( + version_id BIGINT PRIMARY KEY REFERENCES system_prompt_template_versions(id), + preserve_echo BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS system_prompt_frozen_public_files ( + path TEXT PRIMARY KEY, + body BYTEA NOT NULL CHECK (octet_length(body) > 0), + sha256 CHAR(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$') +); + +CREATE OR REPLACE FUNCTION protect_system_prompt_frozen_content() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'independent prompt migration records are immutable'; +END; +$$; + +CREATE TRIGGER trg_protect_prompt_independent_state +BEFORE UPDATE OR DELETE ON system_prompt_independent_state +FOR EACH ROW EXECUTE FUNCTION protect_system_prompt_frozen_content(); +CREATE TRIGGER trg_protect_prompt_independent_contents +BEFORE UPDATE OR DELETE ON system_prompt_independent_contents +FOR EACH ROW EXECUTE FUNCTION protect_system_prompt_frozen_content(); +CREATE TRIGGER trg_protect_prompt_version_compat +BEFORE UPDATE OR DELETE ON system_prompt_version_compat +FOR EACH ROW EXECUTE FUNCTION protect_system_prompt_frozen_content(); +CREATE TRIGGER trg_protect_prompt_frozen_public_files +BEFORE UPDATE OR DELETE ON system_prompt_frozen_public_files +FOR EACH ROW EXECUTE FUNCTION protect_system_prompt_frozen_content(); diff --git a/frontend/src/api/accountOperationIdempotency.ts b/frontend/src/api/accountOperationIdempotency.ts index 88f56590aea3..a679fa25dd68 100644 --- a/frontend/src/api/accountOperationIdempotency.ts +++ b/frontend/src/api/accountOperationIdempotency.ts @@ -44,3 +44,9 @@ export function settleAccountOperation(config?: OperationConfig): void { if (config?.accountOperationSignature) unresolved.delete(config.accountOperationSignature) } export function clearAccountOperationKeys(): void { unresolved.clear(); session++ } + +// Reuse the same actor/session boundary as pending submission keys. Token +// refresh does not invalidate a session; logout/reset does, even for one actor. +export function captureAccountOperationSession(): string { + return `${session}:${currentAccountOperationActor() ?? 'anonymous'}` +} diff --git a/frontend/src/api/admin/__tests__/systemPrompts.spec.ts b/frontend/src/api/admin/__tests__/systemPrompts.spec.ts index e38ee5dfd327..5130d4a171a2 100644 --- a/frontend/src/api/admin/__tests__/systemPrompts.spec.ts +++ b/frontend/src/api/admin/__tests__/systemPrompts.spec.ts @@ -1,124 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() })) -vi.mock('@/api/client', () => ({ apiClient: client })) - -import systemPromptsAPI from '../systemPrompts' -import { rulesAPI } from '../systemPromptRules' - -describe('System Prompts API', () => { - beforeEach(() => Object.values(client).forEach((mock) => mock.mockReset())) - - it('reads and atomically writes v2 content, rules and runtime through one config endpoint', async () => { - client.get.mockResolvedValue({ data: { revision: 7 } }) - client.put.mockResolvedValue({ data: { revision: 8 } }) - await rulesAPI.config() - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/config') - const payload = { expected_revision: 7, enabled: true, expose_server_prompt: false, compact_enabled: true, policy: { version: 2, rules: [], default_rule_ids: [] }, contents: { custom: { body: 'Draft text' } } } - await rulesAPI.saveConfig(payload) - expect(client.put).toHaveBeenCalledWith('/admin/system-prompts/config', payload) - expect(client.post).not.toHaveBeenCalled() - }) - - it('binds paired Skill publication to an explicit affected rule set and both revisions', async () => { - client.post.mockResolvedValue({ data: {} }) - await systemPromptsAPI.publishSkillVersion(12, 3, false, { target_rule_ids: ['first', 'second'], expected_config_revision: 7 }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/12/publish', { expected_revision: 3, expected_config_revision: 7, target_rule_ids: ['first', 'second'] }) - }) - - it('uses the independent admin namespace', async () => { - client.get.mockResolvedValue({ data: { templates: [], runtime: {} } }) - await systemPromptsAPI.list() - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts') - - client.get.mockResolvedValue({ data: { template: {}, versions: [], runtime: {} } }) - await systemPromptsAPI.get(12) - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/12') - - client.get.mockResolvedValue({ data: [] }) - await systemPromptsAPI.listVersions(12) - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/12/versions') - - expect(systemPromptsAPI).not.toHaveProperty('listBundles') - expect(systemPromptsAPI).not.toHaveProperty('getBundle') - - client.get.mockResolvedValue({ data: { runtime: {}, versions: [] } }) - await systemPromptsAPI.getSkillRegistry() - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/skill-registry') - - client.get.mockResolvedValue({ data: { id: 4, verified: true } }) - await systemPromptsAPI.getSkillVersion(4) - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/4') - }) - - it('passes expected revision on every catalog mutation', async () => { - client.post.mockResolvedValue({ data: {} }) - client.patch.mockResolvedValue({ data: {} }) - client.delete.mockResolvedValue({ data: { deleted: true } }) - - await systemPromptsAPI.create({ slug: 'custom', name: 'Custom', description: '', body: 'body', note: '', expected_revision: 7 }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts', expect.objectContaining({ expected_revision: 7 })) - - await systemPromptsAPI.updateMetadata(12, { name: 'Renamed', expected_revision: 7 }) - expect(client.patch).toHaveBeenCalledWith('/admin/system-prompts/12', expect.objectContaining({ expected_revision: 7 })) - - await systemPromptsAPI.saveDraft(12, { - body: 'draft', note: '', expected_latest_version: 3, expected_revision: 7, - composition_mode: 'codex_skill_hybrid', bundle_id: 'codexrip-reverse-skill', - bundle_manifest_sha256: '', - }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/versions', expect.objectContaining({ - expected_latest_version: 3, expected_revision: 7, composition_mode: 'codex_skill_hybrid', - bundle_id: 'codexrip-reverse-skill', bundle_manifest_sha256: '', - })) - - await systemPromptsAPI.duplicate(12, { slug: 'custom-copy', name: 'Copy', expected_revision: 7 }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/duplicate', expect.objectContaining({ expected_revision: 7 })) - - await systemPromptsAPI.remove(12, 7) - expect(client.delete).toHaveBeenCalledWith('/admin/system-prompts/12', { params: { expected_revision: 7 } }) - }) - - it('keeps publish, rollback, and runtime updates explicit', async () => { - client.post.mockResolvedValue({ data: {} }) - client.put.mockResolvedValue({ data: {} }) - - await systemPromptsAPI.publish(12, 33, 7, false, ['custom']) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/versions/33/publish', { expected_revision: 7, target_rule_ids: ['custom'] }) - await systemPromptsAPI.publish(12, 22, 8, true, ['custom']) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/versions/22/rollback', { expected_revision: 8, target_rule_ids: ['custom'] }) - - await systemPromptsAPI.updateRuntime({ expected_revision: 9, enabled: true, expose_server_prompt: false, compact_enabled: true }) - expect(client.put).toHaveBeenCalledWith('/admin/system-prompts/runtime', expect.objectContaining({ expected_revision: 9, enabled: true })) - }) - - it('syncs a managed prompt source without activating the candidate', async () => { - client.post.mockResolvedValue({ data: { status: 'candidate_created', version: { id: 44 } } }) - - await systemPromptsAPI.syncManagedSource(12, { expected_latest_version: 3, expected_revision: 7 }) - - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/upstream-sync', { - expected_latest_version: 3, - expected_revision: 7, - }) - }) - - it('uses the independent skill registry revision for sync and publication', async () => { - client.post.mockResolvedValue({ data: {} }) - client.get.mockResolvedValue({ data: {} }) - - const promptCapture = new File(['capture'], 'capture.txt', { type: 'text/plain' }) - await systemPromptsAPI.startSkillSync(3, promptCapture) - expect(client.post).toHaveBeenCalledTimes(1) - const syncForm = client.post.mock.calls[0][1] as FormData - expect(client.post.mock.calls[0][0]).toBe('/admin/system-prompts/skill-registry/syncs') - expect(syncForm.get('expected_revision')).toBe('3') - expect(syncForm.get('prompt_capture')).toBe(promptCapture) - await systemPromptsAPI.getSkillSync(8) - expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/syncs/8') - await systemPromptsAPI.publishSkillVersion(12, 3, false, { target_rule_ids: ['skill'], expected_config_revision: 8 }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/12/publish', { expected_revision: 3, target_rule_ids: ['skill'], expected_config_revision: 8 }) - await systemPromptsAPI.publishSkillVersion(9, 4, true, { target_rule_ids: ['skill'], expected_config_revision: 9 }) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/9/rollback', { expected_revision: 4, target_rule_ids: ['skill'], expected_config_revision: 9 }) - }) +import { beforeEach, expect, it, vi } from 'vitest' +import { promptConfig } from '@/views/admin/__tests__/systemPromptFixtures' +const client = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn(), post: vi.fn() })) +vi.mock('../../client', () => ({ apiClient: client })) +import prompts from '../systemPrompts' +beforeEach(() => { Object.values(client).forEach(mock => mock.mockReset()); client.get.mockResolvedValue({ data: [] }); client.put.mockResolvedValue({ data: promptConfig() }) }) +it('writes through config and reads history by encoded stable rule identity', async () => { + const payload = { expected_revision: 7, enabled: true, policy: promptConfig().policy, contents: { first: { body: 'restored', restore_version_id: 9 } } } + await prompts.saveConfig(payload) + expect(client.put).toHaveBeenCalledWith('/admin/system-prompts/config', payload) + await prompts.history('rule with spaces') + expect(client.get).toHaveBeenCalledWith('/admin/system-prompts/rules/rule%20with%20spaces/history') + for (const retired of ['save', 'preview', 'publish', 'syncManagedSource', 'startSkillSync', 'publishSkillVersion', 'updateRuntime', 'create', 'saveDraft']) expect(prompts).not.toHaveProperty(retired) }) diff --git a/frontend/src/api/admin/accountJobs.ts b/frontend/src/api/admin/accountJobs.ts index f51acb9d63e2..41434076c55d 100644 --- a/frontend/src/api/admin/accountJobs.ts +++ b/frontend/src/api/admin/accountJobs.ts @@ -35,6 +35,8 @@ export interface AccountJob { finished_at?: string created_at: string updated_at: string + retry_eligible?: boolean + retry_unavailable_reason?: string } export interface AccountJobItem { @@ -175,7 +177,7 @@ async function mergeDuplicates(request: DuplicateMergeRequest): Promise { request_zstd?: boolean proxy_url?: string proxy_protocol?: string + proxy_selection_id?: string } export const codexRuntimeAPI = { diff --git a/frontend/src/api/admin/codexTickets.ts b/frontend/src/api/admin/codexTickets.ts index 8ca38b68f09e..11889dee02c6 100644 --- a/frontend/src/api/admin/codexTickets.ts +++ b/frontend/src/api/admin/codexTickets.ts @@ -12,6 +12,22 @@ export interface ProxyTestResult { stages: Array<{ name: string; success: boolean; duration_ms: number; message?: string }> certificate_trust?: string; certificate_fingerprint?: string; protocol_suggestion?: string } +export interface ProxyCandidate { + selection_id: string + protocol: string + host: string + port: number + username_masked?: string + source_line: number + source_column: number + format: string +} +export interface ProxyParseResult { + candidates: ProxyCandidate[] + issues: Array<{ line: number; column: number; field: string; code: string; message: string }> + selection_id?: string + selection_required: boolean +} export const codexTicketsAPI = { async policy() { return (await apiClient.get<{ enabled: boolean; models: string[] }>('/admin/accounts/codex-tickets/policy')).data @@ -27,8 +43,11 @@ export const codexTicketsAPI = { const path = ids.length === 1 ? `/admin/accounts/${ids[0]}/codex-tickets/stop-job` : '/admin/accounts/codex-tickets/batch-stop' return (await apiClient.post(path, { account_ids: ids, models }, key)).data }, - async testProxy(proxy_url: string, protocol?: string) { - const body = protocol ? { proxy_url, protocol } : { proxy_url } + async parseProxy(proxy_url: string, protocol?: string) { + return (await apiClient.post('/admin/settings/openai-codex-ticket/proxy-parse', { proxy_url, protocol })).data + }, + async testProxy(proxy_url: string, protocol?: string, proxy_selection_id?: string) { + const body = { proxy_url, ...(protocol ? { protocol } : {}), ...(proxy_selection_id ? { proxy_selection_id } : {}) } return (await apiClient.post('/admin/settings/openai-codex-ticket/proxy-test', body, { timeout: 30000 })).data } } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index f02933078e5a..f8d9edd2dc4d 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -144,15 +144,4 @@ export type { CindyBalanceProbeScope, CindyBalanceProbeScopeMode } from './cindyBalanceProbe' -export type { - RemoteSkillBundleVersion, - RemoteSkillBundleVersionDetail, - RemoteSkillRegistryResponse, - RemoteSkillRegistrySnapshot, - RemoteSkillSourceID, - RemoteSkillSyncJob, - SystemPromptCompositionMode, - SystemPromptRuntime, - SystemPromptTemplate, - SystemPromptVersion -} from './systemPrompts' +export type { SystemPromptCompositionMode, PromptConfig, PromptConfigWrite, PromptHistoryVersion } from './systemPrompts' diff --git a/frontend/src/api/admin/systemPromptRules.ts b/frontend/src/api/admin/systemPromptRules.ts index e068f6a6ce76..09cf26f66859 100644 --- a/frontend/src/api/admin/systemPromptRules.ts +++ b/frontend/src/api/admin/systemPromptRules.ts @@ -1,6 +1,6 @@ import { apiClient } from '../client' -import type { SystemPromptCompositionMode } from './systemPrompts' +export type SystemPromptCompositionMode = 'inline' | 'anthropic_system_blocks' | 'codex_skill_hybrid' export type PromptRole = 'auto' | 'system' | 'developer' export type PromptPosition = 'control_prepend' | 'control_append' | 'conversation_head' | 'conversation_tail' | 'before_last_user' | 'after_last_user' @@ -29,7 +29,9 @@ export interface PromptContent { managed: boolean template_id: number version_id: number + restore_version_id?: number } +export interface PromptHistoryVersion { id: number; body: string; composition_mode: SystemPromptCompositionMode; created_at: string; restorable: boolean } export interface PromptCapability { protocol: string platforms: string[] @@ -46,10 +48,10 @@ export interface PromptConfig extends PromptRuleState { export interface PromptConfigWrite { expected_revision: number enabled: boolean - expose_server_prompt: boolean - compact_enabled: boolean + expose_server_prompt?: boolean + compact_enabled?: boolean policy: PromptRulePolicy - contents: Record + contents: Record } export interface PromptBinding { mode: 'inherit' | 'off' | 'custom'; rule_ids: string[] } export interface PromptAccountBinding { @@ -57,18 +59,6 @@ export interface PromptAccountBinding { binding: PromptBinding; effective_rule_ids: string[]; supported: boolean } export interface PromptBindingResult { account_id: number; applied: boolean; code?: string } -export interface PromptPreview { - account_id: number; requested_model: string; upstream_model: string; protocol: string; transport: string - body: unknown; before_rules: unknown; client_control: unknown; gateway_base_instructions: string - continuation: boolean; sequence_scope: string; simulated: boolean; wire_verified: boolean - application: { - applied: boolean; revision: number; rules_plan?: { - sha256: string - placements: Array<{ rule_id: string; version_id: number; protocol: string; position: string; carrier: string; role?: string; index?: number; block_index?: number; body: string; sha256: string }> - skipped: Array<{ rule_id: string; reason: string }> - } - } -} function scopedPromptCapabilities(platform: string, position: PromptPosition, capabilities: PromptCapability[], accountTypes: string[]): PromptCapability[] { const protocols = capabilities.filter(capability => capability.platforms.includes(platform)) if (platform !== 'antigravity') return protocols @@ -97,13 +87,10 @@ const basePath = '/admin/system-prompts' export const rulesAPI = { config: async () => (await apiClient.get(`${basePath}/config`)).data, saveConfig: async (payload: PromptConfigWrite) => (await apiClient.put(`${basePath}/config`, payload)).data, + history: async (ruleID: string) => (await apiClient.get(`${basePath}/rules/${encodeURIComponent(ruleID)}/history`)).data, read: async () => (await apiClient.get(`${basePath}/rules`)).data, - save: async (policy: PromptRulePolicy, revision: number) => - (await apiClient.put(`${basePath}/rules`, { policy, expected_revision: revision })).data, accounts: async (ids: number[]) => (await apiClient.post(`${basePath}/accounts/resolve`, { account_ids: ids })).data, bind: async (updates: Array<{ account_id: number; expected_updated_at: string; binding: PromptBinding }>, revision: number) => (await apiClient.post(`${basePath}/accounts/bindings`, { updates, expected_revision: revision })).data, - preview: async (accountId: number, body: { protocol: string; transport: string; compact: boolean; body: unknown; policy?: PromptRulePolicy; contents?: Record; binding?: PromptBinding; simulate_enabled?: boolean }) => - (await apiClient.post(`${basePath}/rules/preview/${encodeURIComponent(String(accountId))}`, body)).data, } diff --git a/frontend/src/api/admin/systemPrompts.ts b/frontend/src/api/admin/systemPrompts.ts index b2036f2b358b..b05ed6f01566 100644 --- a/frontend/src/api/admin/systemPrompts.ts +++ b/frontend/src/api/admin/systemPrompts.ts @@ -1,423 +1,3 @@ -import { apiClient } from '../client' -import type { PromptConfig } from './systemPromptRules' - -export type SystemPromptCompositionMode = 'inline' | 'codex_skill_hybrid' | 'anthropic_system_blocks' -export type SystemPromptClientMode = 'codex' | 'openai_compatible' -export type RemoteSkillSourceID = 'moxinggang' - -export interface SystemPromptRuntime { - enabled: boolean - expose_server_prompt: boolean - compact_enabled: boolean - template_id: number - version_id: number - template_version: number - revision: number - sha256: string - byte_length: number - degraded: boolean - composition_mode: SystemPromptCompositionMode - bundle_id: string - bundle_manifest_sha256: string - registry_revision?: number - registry_raw_tree_sha256?: string - registry_effective_tree_sha256?: string - registry_prompt_raw_sha256?: string - registry_prompt_effective_sha256?: string - registry_upstream_source_id?: RemoteSkillSourceID - registry_upstream_root?: string - registry_public_root?: string - bundle_available: boolean - bundle_degraded: boolean - degraded_reason?: string - updated_at: string -} - -export interface SystemPromptTemplate { - id: number - slug: string - name: string - description: string - is_seed: boolean - managed_source?: string - created_by?: number - updated_by?: number - created_at: string - updated_at: string -} - -export interface SystemPromptVersion { - id: number - template_id: number - version: number - body: string - sha256: string - byte_length: number - note: string - composition_mode: SystemPromptCompositionMode - bundle_id: string - bundle_manifest_sha256: string - created_by?: number - published_at?: string - published_by?: number - created_at: string - is_active: boolean - source_repository?: string - source_commit?: string - source_version?: string - source_artifact?: string - source_artifact_sha256?: string - source_license_sha256?: string -} - -export interface RemoteSkillBundleVersion { - id: number - upstream_source_id: RemoteSkillSourceID - upstream_root: string - public_root: string - raw_tree_sha256: string - effective_tree_sha256: string - prompt_version_id: number - file_count: number - raw_total_bytes: number - effective_total_bytes: number - added_files: number - modified_files: number - deleted_files: number - script_changes: number - binary_changes: number - fetched_at: string - created_by?: number - published_at?: string - published_by?: number - created_at: string -} - -export interface RemoteSkillPromptVersion { - id: number - raw_sha256: string - effective_sha256: string - diff: string - created_by?: number - created_at: string -} - -export interface RemoteSkillPromptVersionDetail extends RemoteSkillPromptVersion { - raw_body: string - effective_body: string -} - -export interface RemoteSkillFileChange { - path: string - change: 'added' | 'modified' | 'deleted' - kind: string - raw_sha256?: string - effective_sha256?: string - previous_effective_sha256?: string -} - -export interface RemoteSkillBundleVersionDetail extends RemoteSkillBundleVersion { - prompt: RemoteSkillPromptVersionDetail - file_changes: RemoteSkillFileChange[] - verified: boolean -} - -export interface RemoteSkillRegistrySnapshot { - revision: number - active?: RemoteSkillBundleVersion - active_prompt?: RemoteSkillPromptVersion - degraded: boolean - degraded_reason?: string - updated_at: string -} - -export interface RemoteSkillRegistryResponse { - runtime: RemoteSkillRegistrySnapshot - versions: RemoteSkillBundleVersion[] - source: { - upstream_source_id: RemoteSkillSourceID - upstream_root: string - public_root: string - } -} - -export interface RemoteSkillSyncJob { - id: number - status: 'queued' | 'running' | 'succeeded' | 'failed' - progress_stage: string - candidate_bundle_version_id?: number - prompt_capture_provided: boolean - error_code?: string - created_at: string - started_at?: string - completed_at?: string -} - -export type ManagedSourceSyncStatus = 'up_to_date' | 'no_prompt_change' | 'candidate_created' - -export interface ManagedSourceSyncVersion { - id: number - template_id: number - version: number - sha256: string - byte_length: number - source_repository?: string - source_commit?: string - source_version?: string - source_artifact?: string - source_artifact_sha256?: string - source_license_sha256?: string -} - -export interface ManagedSourceSyncResponse { - status: ManagedSourceSyncStatus - version?: ManagedSourceSyncVersion -} - -export interface SystemPromptListResponse { - templates: SystemPromptTemplate[] - runtime: SystemPromptRuntime -} - -export interface SystemPromptDetailResponse { - template: SystemPromptTemplate - versions: SystemPromptVersion[] - runtime: SystemPromptRuntime -} - -export interface CreateSystemPromptRequest { - slug: string - name: string - description: string - body: string - note: string - composition_mode?: SystemPromptCompositionMode - bundle_id?: string - bundle_manifest_sha256?: string - expected_revision: number -} - -export interface PreviewUpstreamResponse { - body: unknown - client_mode: SystemPromptClientMode - base_server_instructions: string - final_server_instructions: string - application: { - applied: boolean - carrier: string - client_instructions: string - server_instructions: string - revision: number - sha256: string - base_sha256?: string - effective_sha256?: string - effective_byte_length?: number - bundle_id?: string - bundle_manifest_sha256?: string - bundle_revision?: number - bundle_raw_tree_sha256?: string - bundle_effective_tree_sha256?: string - bundle_prompt_raw_sha256?: string - bundle_prompt_effective_sha256?: string - bundle_upstream_source_id?: string - bundle_upstream_root?: string - bundle_public_root?: string - degraded?: boolean - degraded_reason?: string - } -} - -export async function list(): Promise { - const { data } = await apiClient.get('/admin/system-prompts') - return data -} - -export async function get(id: number): Promise { - const { data } = await apiClient.get(`/admin/system-prompts/${id}`) - return data -} - -export async function listVersions(id: number): Promise { - const { data } = await apiClient.get(`/admin/system-prompts/${id}/versions`) - return data -} - -export async function getSkillRegistry(): Promise { - const { data } = await apiClient.get('/admin/system-prompts/skill-registry') - return data -} - -export async function getSkillVersion(id: number): Promise { - const { data } = await apiClient.get(`/admin/system-prompts/skill-registry/versions/${id}`) - return data -} - -export async function startSkillSync( - expectedRevision: number, - promptCapture?: File -): Promise { - const form = new FormData() - form.append('expected_revision', String(expectedRevision)) - if (promptCapture) form.append('prompt_capture', promptCapture) - const { data } = await apiClient.post('/admin/system-prompts/skill-registry/syncs', form) - return data -} - -export async function getSkillSync(id: number): Promise { - const { data } = await apiClient.get(`/admin/system-prompts/skill-registry/syncs/${id}`) - return data -} - -export async function publishSkillVersion( - id: number, - expectedRevision: number, - rollback: boolean, - targets: { target_rule_ids: string[]; expected_config_revision: number }, -): Promise { - const action = rollback ? 'rollback' : 'publish' - const { data } = await apiClient.post( - `/admin/system-prompts/skill-registry/versions/${id}/${action}`, - { expected_revision: expectedRevision, ...targets } - ) - return data -} - -export async function create(payload: CreateSystemPromptRequest): Promise { - const { data } = await apiClient.post('/admin/system-prompts', payload) - return data -} - -export async function updateMetadata( - id: number, - payload: { name?: string; description?: string; expected_revision: number } -): Promise { - const { data } = await apiClient.patch(`/admin/system-prompts/${id}`, payload) - return data -} - -export async function saveDraft( - id: number, - payload: { - body: string - note: string - composition_mode: SystemPromptCompositionMode - bundle_id: string - bundle_manifest_sha256: string - expected_latest_version: number - expected_revision: number - } -): Promise { - const { data } = await apiClient.post(`/admin/system-prompts/${id}/versions`, payload) - return data -} - -export async function syncManagedSource( - id: number, - payload: { expected_latest_version: number; expected_revision: number } -): Promise { - const { data } = await apiClient.post( - `/admin/system-prompts/${id}/upstream-sync`, - payload - ) - return data -} - -export async function publish( - id: number, - versionId: number, - expectedRevision: number, - rollback: boolean, - targetRuleIDs: string[], -): Promise { - const action = rollback ? 'rollback' : 'publish' - const { data } = await apiClient.post( - `/admin/system-prompts/${id}/versions/${versionId}/${action}`, - { expected_revision: expectedRevision, target_rule_ids: targetRuleIDs } - ) - return data -} - -export async function updateRuntime(payload: { - expected_revision: number - enabled: boolean - expose_server_prompt: boolean - compact_enabled: boolean -}): Promise { - const { data } = await apiClient.put('/admin/system-prompts/runtime', payload) - return data -} - -export async function duplicate( - id: number, - payload: { slug: string; name: string; expected_revision: number } -): Promise { - const { data } = await apiClient.post(`/admin/system-prompts/${id}/duplicate`, payload) - return data -} - -export async function remove(id: number, expectedRevision: number): Promise<{ deleted: boolean }> { - const { data } = await apiClient.delete<{ deleted: boolean }>(`/admin/system-prompts/${id}`, { - params: { expected_revision: expectedRevision } - }) - return data -} - -export async function previewMerge(payload: { - template_id?: number - version_id?: number - client_instructions: string - server_instructions?: string - composition_mode?: SystemPromptCompositionMode - bundle_id?: string - bundle_manifest_sha256?: string - body?: unknown - client_mode: SystemPromptClientMode -}): Promise<{ - instructions: string - client_mode: SystemPromptClientMode - base_server_instructions: string - final_server_instructions: string - application: PreviewUpstreamResponse['application'] -}> { - const { data } = await apiClient.post('/admin/system-prompts/preview/merge', payload) - return data -} - -export async function previewUpstream(payload: { - template_id: number - version_id: number - server_instructions?: string - composition_mode?: SystemPromptCompositionMode - bundle_id?: string - bundle_manifest_sha256?: string - protocol: 'responses' | 'chat' - compact: boolean - body: unknown - client_mode: SystemPromptClientMode -}): Promise { - const { data } = await apiClient.post('/admin/system-prompts/preview/upstream', payload) - return data -} - -export const systemPromptsAPI = { - list, - get, - listVersions, - getSkillRegistry, - getSkillVersion, - startSkillSync, - getSkillSync, - publishSkillVersion, - create, - updateMetadata, - saveDraft, - syncManagedSource, - publish, - updateRuntime, - duplicate, - remove, - previewMerge, - previewUpstream -} - -export default systemPromptsAPI +// Public admin API facade. All prompt writes share the atomic config endpoint. +export { rulesAPI as systemPromptsAPI, rulesAPI as default } from './systemPromptRules' +export type { SystemPromptCompositionMode, PromptConfig, PromptConfigWrite, PromptHistoryVersion } from './systemPromptRules' diff --git a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue index 995222813d19..03653ddc9f83 100644 --- a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue +++ b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue @@ -52,6 +52,9 @@

{{ item.metadata.message }}

{{ item.error_message }}

+

{{ item.error_code }}

+

{{ t('admin.accounts.batchTest.outputLimited') }}

+

{{ t(`admin.accounts.batchTest.recovery.${item.metadata.recovery_status}`) }}

{{ fact.label[locale || 'zh'] || fact.label.zh || fact.label.en }}
@@ -71,7 +74,8 @@ {{ t(terminal ? 'admin.accountTasks.savedInHistory' : 'admin.accountTasks.continuesInBackground') }}
- + + {{ t('admin.accountTasks.retryExpired') }}
diff --git a/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts b/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts index a738890bf76c..f4a1c010d8c4 100644 --- a/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts +++ b/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts @@ -47,7 +47,7 @@ describe('account operation presentation', () => { await button(wrapper, '.cancel').trigger('click'); await flushPromises() expect(button(wrapper, '.stopping').attributes('disabled')).toBeDefined() expect(button(wrapper, '.retryFailed')).toBeUndefined() - store.currentJob = { ...base, status: 'partially_succeeded', processed_count: 2, failed_count: 1 } + store.currentJob = { ...base, status: 'partially_succeeded', processed_count: 2, failed_count: 1, retry_eligible: true } await store.loadCurrent(base.id) await flushPromises() expect(api.listItems.mock.calls.at(-1)?.[1].status).toBe('failed') @@ -72,7 +72,7 @@ describe('account operation presentation', () => { expect(store.currentJob?.id).toBe(52) }) it('keeps expired retry failures actionable without claiming success', async () => { - const store = useAccountJobsStore(); store.track({ ...base, status: 'failed', failed_count: 2 }) + const store = useAccountJobsStore(); store.track({ ...base, status: 'failed', failed_count: 2, retry_eligible: true }) const wrapper = mount(AccountTaskDrawer, options); wrappers.push(wrapper); await flushPromises() api.retryFailed.mockRejectedValue({ code: 'ACCOUNT_JOB_PAYLOAD_EXPIRED' }) await button(wrapper, '.retryFailed').trigger('click'); await flushPromises() diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index f8b83248f558..b52212fbe779 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -980,6 +980,7 @@ const handleEvent = (event: { text?: string model?: string success?: boolean + output_limited?: boolean error?: string image_url?: string audio_url?: string @@ -1067,6 +1068,7 @@ const handleEvent = (event: { } if (event.success) { status.value = 'success' + if (event.output_limited) addLine(t('admin.accounts.batchTest.outputLimited'), 'text-cyan-300') } else { status.value = 'error' errorMessage.value = event.error || t('admin.accounts.testFailed') diff --git a/frontend/src/components/admin/account/BatchTestAccountModal.vue b/frontend/src/components/admin/account/BatchTestAccountModal.vue index 1e25fc6c0132..9e2c0a06545a 100644 --- a/frontend/src/components/admin/account/BatchTestAccountModal.vue +++ b/frontend/src/components/admin/account/BatchTestAccountModal.vue @@ -2,6 +2,9 @@

{{ t('admin.accounts.batchTest.description', { count: rows.length }) }}

+

{{ t('admin.accounts.batchTest.automaticHint') }}

+
+ {{ t('admin.accounts.batchTest.advanced') }}

{{ applicationResult }}

@@ -11,6 +14,11 @@
+ + +
@@ -29,6 +38,7 @@

{{ t('admin.accounts.batchTest.resolveBeforeStart') }}

+