From 39e8935fba2126a3697e6a8e1df92734c69304df Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 12:07:08 +0800 Subject: [PATCH 1/2] feat(prompts): unify editing and final-protocol delivery --- .downstream/gpt6-routing-and-prompts.md | 28 +- .github/workflows/downstream-verify.yml | 2 + backend/cmd/server/wire_gen.go | 22 +- .../handler/admin/setting_handler_update.go | 5 + .../admin/system_prompt_config_handler.go | 37 + .../handler/admin/system_prompt_handler.go | 29 +- .../system_prompt_legacy_settings_test.go | 34 + backend/internal/nativeapi/prompt.go | 23 +- backend/internal/nativeapi/prompt_rules.go | 94 +- .../promptskills/policy/management.go | 2 +- .../internal/promptskills/policy/module.go | 89 +- .../promptskills/policy/module_test.go | 48 +- backend/internal/promptskills/policy/rules.go | 354 ++++++-- .../promptskills/policy/rules_test.go | 274 +++++- .../business_system_prompt_config.go | 205 +++++ .../business_system_prompt_config_test.go | 182 ++++ .../business_system_prompt_repository.go | 47 +- .../business_system_prompt_rules.go | 84 +- ...ss_system_prompt_rules_integration_test.go | 211 ++++- .../business_system_prompt_rules_test.go | 50 +- .../remote_skill_prompt_targets_test.go | 41 + .../remote_skill_registry_repository.go | 54 ++ .../internal/server/middleware/audit_log.go | 15 +- .../server/middleware/audit_log_test.go | 71 ++ backend/internal/server/routes/admin.go | 2 + .../service/antigravity_credits_overages.go | 3 +- .../service/antigravity_gateway_claude.go | 1 + .../service/antigravity_gateway_compat.go | 2 + .../service/antigravity_gateway_gemini.go | 5 +- .../service/antigravity_gateway_retry.go | 6 +- .../service/antigravity_gateway_service.go | 17 +- .../service/antigravity_gateway_streaming.go | 4 +- .../service/antigravity_gateway_upstream.go | 13 +- ...siness_system_prompt_account_scope_test.go | 688 +++----------- ...iness_system_prompt_cache_identity_test.go | 25 +- .../business_system_prompt_composition.go | 4 +- .../service/business_system_prompt_echo.go | 88 ++ .../business_system_prompt_gateway_test.go | 45 +- ..._system_prompt_offline_integration_test.go | 68 +- .../service/business_system_prompt_policy.go | 153 +--- ...business_system_prompt_reasoning_source.go | 88 ++ .../business_system_prompt_replay_scope.go | 34 + .../business_system_prompt_retry_source.go | 72 ++ .../business_system_prompt_rules_apply.go | 208 ++--- ...siness_system_prompt_rules_gateway_test.go | 31 +- .../business_system_prompt_rules_insert.go | 382 ++++++++ ...usiness_system_prompt_rules_observation.go | 6 - .../business_system_prompt_rules_preview.go | 76 +- .../business_system_prompt_rules_service.go | 54 +- .../business_system_prompt_rules_test.go | 20 +- .../business_system_prompt_rules_v2_test.go | 183 ++++ .../business_system_prompt_rules_ws.go | 30 +- .../service/business_system_prompt_send.go | 277 ++++++ .../business_system_prompt_send_test.go | 194 ++++ .../service/business_system_prompt_service.go | 66 +- .../service/business_system_prompt_test.go | 211 +++-- .../business_system_prompt_turn_cache.go | 33 +- .../business_system_prompt_turn_cache_test.go | 10 +- .../business_system_prompt_v2_fixture_test.go | 30 + ...s_system_prompt_ws_requested_model_test.go | 115 +++ .../service/gateway_anthropic_passthrough.go | 9 +- backend/internal/service/gateway_bedrock.go | 10 +- .../service/gateway_claude_oauth_body.go | 78 +- .../internal/service/gateway_count_tokens.go | 34 +- backend/internal/service/gateway_forward.go | 14 +- .../gateway_forward_as_chat_completions.go | 7 +- .../service/gateway_forward_as_responses.go | 7 +- backend/internal/service/gateway_service.go | 1 + .../internal/service/gateway_system_prompt.go | 119 +++ .../service/gateway_system_prompt_finalize.go | 71 ++ .../service/gateway_system_prompt_preview.go | 201 +++++ .../service/gateway_system_prompt_test.go | 356 ++++++++ .../service/gateway_upstream_request.go | 11 + .../service/gateway_upstream_response.go | 5 +- .../gemini_chat_completions_compat_service.go | 5 + .../service/gemini_messages_compat_service.go | 36 +- .../service/openai_business_system_prompt.go | 551 +----------- .../service/openai_chat_reasoning_replay.go | 4 + .../service/openai_gateway_cc_pipeline.go | 11 +- .../openai_gateway_chat_completions.go | 87 +- .../openai_gateway_chat_completions_raw.go | 14 - .../service/openai_gateway_forward.go | 78 +- .../internal/service/openai_gateway_grok.go | 30 +- .../openai_gateway_grok_chat_bridge.go | 2 +- .../service/openai_gateway_messages.go | 37 +- ...penai_gateway_messages_anthropic_native.go | 8 +- .../openai_gateway_messages_chat_fallback.go | 15 - .../service/openai_gateway_passthrough.go | 56 +- .../openai_gateway_responses_chat_fallback.go | 22 +- .../service/openai_ws_forwarder_ingress.go | 87 +- .../service/openai_ws_forwarder_v2.go | 16 +- .../openai_ws_v2_passthrough_adapter.go | 2 +- .../internal/service/plugin_prompt_test.go | 91 +- backend/internal/service/prompt_config.go | 295 ++++++ .../service/prompt_config_generation_test.go | 138 +++ .../internal/service/prompt_config_test.go | 141 +++ .../internal/service/prompt_domain_runtime.go | 81 +- .../internal/service/remote_skill_registry.go | 30 +- backend/internal/service/wire.go | 77 +- .../migrations/255_prompt_rule_policy_v2.sql | 230 +++++ .../api/admin/__tests__/systemPrompts.spec.ts | 34 +- frontend/src/api/admin/systemPromptRules.ts | 72 +- frontend/src/api/admin/systemPrompts.ts | 17 +- .../admin/systemPrompt/PromptRulesManager.vue | 273 +++--- .../SystemPromptAdvancedDrawer.vue | 15 +- .../__tests__/PromptRules.spec.ts | 83 +- .../composables/useSystemPromptConfigDraft.ts | 158 ++++ .../src/i18n/locales/en/admin/settings.ts | 9 +- .../locales/en/admin/systemPromptRules.ts | 40 +- .../src/i18n/locales/zh/admin/settings.ts | 9 +- .../locales/zh/admin/systemPromptRules.ts | 40 +- frontend/src/utils/accountPromptBinding.ts | 6 +- frontend/src/views/admin/SettingsView.vue | 630 +------------ .../src/views/admin/SystemPromptsView.vue | 845 +++++------------- .../admin/__tests__/SettingsView.spec.ts | 35 +- .../SystemPromptsView.locale.spec.ts | 92 +- .../admin/__tests__/SystemPromptsView.spec.ts | 663 +++++--------- .../admin/__tests__/systemPromptFixtures.ts | 26 + .../systemPromptsIntegrationSurface.spec.ts | 4 +- 119 files changed, 6847 insertions(+), 4516 deletions(-) create mode 100644 backend/internal/handler/admin/system_prompt_config_handler.go create mode 100644 backend/internal/handler/admin/system_prompt_legacy_settings_test.go create mode 100644 backend/internal/repository/business_system_prompt_config.go create mode 100644 backend/internal/repository/business_system_prompt_config_test.go create mode 100644 backend/internal/repository/remote_skill_prompt_targets_test.go create mode 100644 backend/internal/service/business_system_prompt_echo.go create mode 100644 backend/internal/service/business_system_prompt_reasoning_source.go create mode 100644 backend/internal/service/business_system_prompt_replay_scope.go create mode 100644 backend/internal/service/business_system_prompt_retry_source.go create mode 100644 backend/internal/service/business_system_prompt_rules_insert.go create mode 100644 backend/internal/service/business_system_prompt_rules_v2_test.go create mode 100644 backend/internal/service/business_system_prompt_send.go create mode 100644 backend/internal/service/business_system_prompt_send_test.go create mode 100644 backend/internal/service/business_system_prompt_v2_fixture_test.go create mode 100644 backend/internal/service/business_system_prompt_ws_requested_model_test.go create mode 100644 backend/internal/service/gateway_system_prompt.go create mode 100644 backend/internal/service/gateway_system_prompt_finalize.go create mode 100644 backend/internal/service/gateway_system_prompt_preview.go create mode 100644 backend/internal/service/gateway_system_prompt_test.go create mode 100644 backend/internal/service/prompt_config.go create mode 100644 backend/internal/service/prompt_config_generation_test.go create mode 100644 backend/internal/service/prompt_config_test.go create mode 100644 backend/migrations/255_prompt_rule_policy_v2.sql create mode 100644 frontend/src/composables/useSystemPromptConfigDraft.ts create mode 100644 frontend/src/views/admin/__tests__/systemPromptFixtures.ts diff --git a/.downstream/gpt6-routing-and-prompts.md b/.downstream/gpt6-routing-and-prompts.md index 28c4327042f8..0d639344c34c 100644 --- a/.downstream/gpt6-routing-and-prompts.md +++ b/.downstream/gpt6-routing-and-prompts.md @@ -1,6 +1,6 @@ # GPT-6 模型、Codex 路由与提示词 -本轮以官方 v0.2.7 和下游生产提交 `300bd6dc5c40191d935cc08db910942f996bb26f` 为基线。宿主目标版本为 `0.2.7-codexrip.2`,七个内置插件为 `0.2.8`;新版插件要求宿主至少为该下游版本。宿主每次发布都会重新签名内置包,已安装的同版本号不能换包,因此 `0.2.7-codexrip.4` 起七个插件为 `0.2.9`,合并官方 v0.2.8 后的 `0.2.8-codexrip.1` 起为 `0.2.10`,加入 Codex 质量诊断的 `0.2.8-codexrip.2` 起为 `0.2.11`(宿主范围放宽到 `<0.2.9`,下限不变,便于回退到 0.2.7 宿主)。官方 v0.2.8 自带 GPT-6 Sol/Luna 定价与识别,下游改用官方实现,只保留 Codex 专属的路由门控、指令模板、实时容量和日期快照不改名的约定。插件固定版本意图继续保留,旧提示词插件不能解释新规则时明确报告不可用。 +官方基线见 [upstream-base](upstream-base)。内置提示词能力在宿主内原生运行,随宿主交付;第三方插件框架独立保留。GPT-6 Sol/Luna 的定价与识别沿用官方实现,Codex 专属路由、指令模板、实时容量和日期快照约定见下文。 ## 模型同步与容量 @@ -39,20 +39,36 @@ API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字 ## 提示词规则与账号选择 -系统提示词页面的“应用规则”复用原有模板及不可变版本。规则稳定排序,可按请求模型或最终上游模型精确匹配;空模型集合表示全部。 +系统提示词页面使用一个编辑器管理正文、角色、位置、平台/模型范围和默认选择。“保存并生效”在一个事务中创建不可变内容版本、更新规则和开关,只递增一次 revision。历史版本、来源同步和远程 Skill 位于高级入口;不存在第二个“设为全局当前模板”的生效步骤。 + +管理员 `GET /api/v1/admin/system-prompts/config` 返回配置、按规则 ID 索引的正文和协议能力。`PUT` 提交 `expected_revision`、运行开关、v2 `policy` 及仅包含改动正文的 `contents`。新规则可以携带零模板/版本 ID 和正文,也可以明确引用已有不可变版本;事务内生成真实引用。固定版本的旧规则 ID、默认选择、账号引用和顺序保留,v1 写入及无目标规则的旧发布操作明确拒绝。 | 位置 | 含义 | | --- | --- | | control_prepend / control_append | 控制指令区前后 | | conversation_head / conversation_tail | 本次最终发送的消息数组头尾 | +| before_last_user / after_last_user | 本次序列中最后一条真实用户消息的前后,纯工具结果不算用户输入 | + +v2 使用独立 `role=auto/system/developer` 与 `position`。平台必须显式选择,模型默认按最终上游 ID 精确匹配;空模型集合表示全部,也可选择按请求模型匹配。同一落点按列表顺序排列。 + +| 最终协议 | 实际投递 | +| --- | --- | +| Responses | auto 控制区使用 instructions,auto 消息位置使用 developer;显式角色保留在 input。Codex OAuth 不接受 system。 | +| Chat Completions | auto 使用 system;明确要求 system 的目标不接受严格 developer,不做静默角色替换。 | +| Messages | 控制区使用顶层 system;会话内 system 仅对明确支持的模型及合法边界开放。developer 不支持。 | +| Gemini | 使用 systemInstruction,系统指令只支持控制区前后。Antigravity upstream 账号按其实际 Messages 协议处理。 | -发送方式与位置分开:`native_control` 使用目标原生控制字段,仅用于控制区;`system` 和 `developer` 要求最终发包保留对应显式角色。Codex OAuth 不接受严格 system 的组合会明确返回不支持,原生模式标明实际使用 `instructions`。续接数组位置不包含 `previous_response_id` 指向的隐藏历史。 +找不到最后用户消息,或插入会拆开工具调用配对时,跳过该条规则并返回原因,请求继续。角色/位置与目标不兼容则明确返回错误。位置只指本次出站序列,不包含 previous_response_id 指向的隐藏历史。 账号详情和账号批量操作提供 `inherit / off / custom`:继承默认规则、关闭、或用指定规则替换默认集合。默认集合留空、指定账号选择 custom,即可只对指定账号开启。账号仅存规则引用,不复制正文;shadow 使用自身绑定。 -预览选择账号、模型、协议、HTTP/WS和样例请求,显示网关基础模板、客户指令、站点规则及最终结构。它不访问上游。实际发送前执行同一载体校验,覆盖 Responses、Chat、转换链路和 WS passthrough;重试及换账号精确撤回本站插入,不按相同正文猜测客户内容。规则变化隔离缓存,保留续接标识和已有回显设置。 +预览接受未保存正文和规则,使用实际协议转换与同一个编排器,展示基础指令、客户指令、最终角色/载体/索引及跳过原因,不访问模型上游。OAuth 身份参数等模拟部分会标明。实际转发先转换协议和准备基础指令,再应用规则、生成缓存标识/签名并发送;每次重试从干净输入构建,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 一起在事务内比较。普通来源同步只创建候选,应用历史正文只更新指定规则。来源不可用不会阻止无关内联规则,也不妨碍关闭总开关;命中不可用来源的请求明确失败。 -迁移 `253_prompt_rule_policies.sql` 为旧活动版本建立默认规则,保留原总开关、账号范围与 256 KiB 合法编译上限。配置和账号绑定均使用版本比较,冲突需要重新读取后处理;被账号引用的规则不能直接删除。 +配置冲突保留编辑器草稿,重新读取后可合并未编辑的远端字段。账号仍用自身版本比较;被账号引用的规则不能直接删除。 ## 界面与验证边界 @@ -60,4 +76,4 @@ API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字 离线真实 Chromium 验证了左侧导航、分类栏和右侧账号详情:两次正常 15 秒刷新、未保存输入与滚动保留、主题失败恢复、真实字体加载、菜单伸缩及跨 lg 布局。跨 lg 后表格切换为文档流时,其原容器滚动值会归零,这是原有响应式行为,不属于本轮刷新验收结果。 -本地测试只覆盖实际修改风险,正常 PR 必需检查继续执行。真实验证须另用本次固定账本:一个账号,Astra/Sol/Luna 各最多采集与复验一次,另最多一次 WS;每次 25 秒、128 KiB 读取上限,失败不追加请求。真实结果、生产镜像及部署指针由工作区接手手册和本次交付证据维护;本文件不把离线成功写成线上模型质量证明。 +本地测试只覆盖实际修改风险,正常 PR 必需检查继续执行;同代码的成功结果复用。提示词重构使用离线编排、模拟上游与隔离 PostgreSQL 验证,不发真实模型请求。生产镜像及部署指针由工作区接手手册和交付证据维护,离线或部署健康结果不代表模型质量。 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index b9d62e440045..440d89fc1803 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -189,6 +189,8 @@ jobs: src/components/account/__tests__/BulkEditAccountModal.spec.ts \ src/views/admin/__tests__/SystemPromptsView.spec.ts \ src/views/admin/__tests__/SystemPromptsView.locale.spec.ts \ + src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts \ + src/api/admin/__tests__/systemPrompts.spec.ts \ src/components/admin/systemPrompt/__tests__/PromptRules.spec.ts \ src/views/admin/__tests__/AccountsView.console.spec.ts \ src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts \ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 876e46de1eb8..658d83bf3034 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -154,14 +154,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository) notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService) balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService) - gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, compositeRouteResolver, balanceNotifyService, serviceUserPlatformQuotaRepository) - openAIOAuthClient := repository.NewOpenAIOAuthClient() - privacyClientFactory := providePrivacyClientFactory() - openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) - openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) - grokOAuthClient := repository.NewGrokOAuthClient() - grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient) - grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache) businessSystemPromptStore := repository.NewBusinessSystemPromptRepository(db) businessSystemPromptRevisionBus := repository.NewBusinessSystemPromptRevisionBus(redisClient) remoteSkillRegistryStore := repository.NewRemoteSkillRegistryRepository(db) @@ -172,10 +164,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { if err != nil { return nil, err } - businessSystemPromptService, err := service.ProvideBusinessSystemPromptService(businessSystemPromptStore, businessSystemPromptRevisionBus, remoteSkillRegistryService, remoteSkillRegistryRevisionBus, accountRepository, configConfig) + businessSystemPromptService, err := service.ProvideBusinessSystemPromptService(businessSystemPromptStore, businessSystemPromptRevisionBus, remoteSkillRegistryService, remoteSkillRegistryRevisionBus, accountRepository, configConfig, settingService) if err != nil { return nil, err } + gatewayService := service.ProvideGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, compositeRouteResolver, balanceNotifyService, serviceUserPlatformQuotaRepository, businessSystemPromptService) + openAIOAuthClient := repository.NewOpenAIOAuthClient() + privacyClientFactory := providePrivacyClientFactory() + openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) + openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) + grokOAuthClient := repository.NewGrokOAuthClient() + grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient) + grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache) openAIGatewayService := service.ProvideOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository, businessSystemPromptService) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() @@ -185,8 +185,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache) internal500CounterCache := repository.NewInternal500CounterCache(redisClient) - antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache) - geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) + antigravityGatewayService := service.ProvideAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache, businessSystemPromptService) + geminiMessagesCompatService := service.ProvideGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig, businessSystemPromptService) opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) authCacheInvalidationOutboxRepository := repository.NewAuthCacheInvalidationOutboxRepository(db) authCacheInvalidationWorker := service.ProvideAuthCacheInvalidationWorker(authCacheInvalidationOutboxRepository, apiKeyCache, apiKeyService) diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index cad3150561b6..8cf7498b5ffc 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -516,6 +516,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.ErrorFrom(c, err) return } + if (req.ClaudeOAuthSystemPrompt != nil && *req.ClaudeOAuthSystemPrompt != previousSettings.ClaudeOAuthSystemPrompt) || + (req.ClaudeOAuthSystemPromptBlocks != nil && *req.ClaudeOAuthSystemPromptBlocks != previousSettings.ClaudeOAuthSystemPromptBlocks) { + response.ErrorWithDetails(c, http.StatusConflict, "Custom prompts are managed in System Prompts; use /api/v1/admin/system-prompts/config", "prompt_configuration_moved", nil) + return + } previousAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/system_prompt_config_handler.go b/backend/internal/handler/admin/system_prompt_config_handler.go new file mode 100644 index 000000000000..18a7d20e83b5 --- /dev/null +++ b/backend/internal/handler/admin/system_prompt_config_handler.go @@ -0,0 +1,37 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *SystemPromptHandler) Config(c *gin.Context) { + state, err := h.service.PromptConfig(c.Request.Context()) + if err != nil { + writeBusinessSystemPromptError(c, err) + return + } + c.Header("Cache-Control", "no-store") + response.Success(c, state) +} + +func (h *SystemPromptHandler) SaveConfig(c *gin.Context) { + var request service.PromptConfigUpdate + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "Invalid prompt configuration") + return + } + actor, ok := h.actorID(c) + if !ok { + return + } + state, err := h.service.SavePromptConfig(c.Request.Context(), request, actor) + if err != nil { + writeBusinessSystemPromptError(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"revision": state.Revision, "rule_count": len(state.Policy.Rules), "result": "prompt_config_saved"}) + response.Success(c, state) +} diff --git a/backend/internal/handler/admin/system_prompt_handler.go b/backend/internal/handler/admin/system_prompt_handler.go index 9323dc7c1402..42ad5cac1a5f 100644 --- a/backend/internal/handler/admin/system_prompt_handler.go +++ b/backend/internal/handler/admin/system_prompt_handler.go @@ -403,18 +403,28 @@ func (h *SystemPromptHandler) publishSkillVersion(c *gin.Context, action, result return } var req struct { - ExpectedRevision int64 `json:"expected_revision" binding:"required"` + 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() - snapshot, err := h.skillRegistry.PublishVersionAction(c.Request.Context(), versionID, req.ExpectedRevision, action, actorID) + 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, @@ -654,7 +664,8 @@ func (h *SystemPromptHandler) publish(c *gin.Context, action, result string) { return } var req struct { - ExpectedRevision int64 `json:"expected_revision"` + ExpectedRevision int64 `json:"expected_revision"` + TargetRuleIDs []string `json:"target_rule_ids"` } if c.Request.ContentLength != 0 { if err := c.ShouldBindJSON(&req); err != nil { @@ -665,20 +676,16 @@ func (h *SystemPromptHandler) publish(c *gin.Context, action, result string) { if req.ExpectedRevision == 0 { req.ExpectedRevision, _ = strconv.ParseInt(strings.TrimSpace(c.Query("expected_revision")), 10, 64) } - oldSnapshot, _ := h.service.CurrentSnapshot() - snapshot, err := h.service.PublishVersionAction(c.Request.Context(), templateID, versionID, req.ExpectedRevision, action, actorID) + 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, "template_version": snapshot.TemplateVersion, - "old_sha256": oldSnapshot.SHA256, "new_sha256": snapshot.SHA256, - "byte_length": snapshot.ByteLength, "revision": snapshot.Revision, "result": result, - "composition_mode": snapshot.CompositionMode, "bundle_id": snapshot.BundleID, - "bundle_manifest_sha256": snapshot.BundleManifestSHA256, "degraded": snapshot.Degraded, + "template_id": templateID, "version_id": versionID, + "rule_ids": req.TargetRuleIDs, "revision": state.Revision, "result": result, }) - response.Success(c, businessSystemPromptRuntimeResponse(snapshot)) + response.Success(c, state) } func (h *SystemPromptHandler) Duplicate(c *gin.Context) { diff --git a/backend/internal/handler/admin/system_prompt_legacy_settings_test.go b/backend/internal/handler/admin/system_prompt_legacy_settings_test.go new file mode 100644 index 000000000000..1dace9d8057d --- /dev/null +++ b/backend/internal/handler/admin/system_prompt_legacy_settings_test.go @@ -0,0 +1,34 @@ +//go:build unit + +package admin + +import ( + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestUpdateSettingsRejectsLegacyPromptEdits(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyClaudeOAuthSystemPrompt: "old prompt", + service.SettingKeyClaudeOAuthSystemPromptBlocks: `[{"type":"text","text":"old block"}]`, + }) + for _, key := range []string{"claude_oauth_system_prompt", "claude_oauth_system_prompt_blocks"} { + t.Run(key, func(t *testing.T) { + result := doUpdateSettings(t, h, map[string]any{key: "changed", "site_name": "must not be saved"}, nil) + require.Equal(t, http.StatusConflict, result.Code) + require.Contains(t, result.Body.String(), "prompt_configuration_moved") + require.NotEqual(t, "must not be saved", repo.values[service.SettingKeySiteName]) + }) + } + result := doUpdateSettings(t, h, map[string]any{ + "claude_oauth_system_prompt": "old prompt", + "claude_oauth_system_prompt_blocks": `[{"type":"text","text":"old block"}]`, + "enable_claude_oauth_system_prompt_injection": false, + }, nil) + require.Equal(t, http.StatusOK, result.Code) + require.Equal(t, "old prompt", repo.values[service.SettingKeyClaudeOAuthSystemPrompt]) + require.Equal(t, "false", repo.values[service.SettingKeyEnableClaudeOAuthSystemPromptInjection]) +} diff --git a/backend/internal/nativeapi/prompt.go b/backend/internal/nativeapi/prompt.go index 8e1caa8b2f36..58cd0d25404a 100644 --- a/backend/internal/nativeapi/prompt.go +++ b/backend/internal/nativeapi/prompt.go @@ -37,19 +37,22 @@ type BusinessSystemPromptSnapshot struct { BaseSHA256 string `json:"-"` EffectiveSHA256 string `json:"-"` EffectiveByteLength int `json:"-"` + Draft bool `json:"-"` } type BusinessSystemPromptTarget struct { - RequestedModel string `json:"requested_model,omitempty"` - UpstreamModel string `json:"upstream_model,omitempty"` - ProviderPlatform string `json:"provider_platform,omitempty"` - ProviderProfile string `json:"provider_profile,omitempty"` - BindingJSON string `json:"binding_json,omitempty"` - AccountID int64 `json:"account_id,omitempty"` - Platform string `json:"platform"` - AccountType string `json:"account_type,omitempty"` - Protocol string `json:"protocol"` - Compact bool `json:"compact"` + RequestedModel string `json:"requested_model,omitempty"` + UpstreamModel string `json:"upstream_model,omitempty"` + ProviderPlatform string `json:"provider_platform,omitempty"` + ProviderProfile string `json:"provider_profile,omitempty"` + BindingJSON string `json:"binding_json,omitempty"` + AccountID int64 `json:"account_id,omitempty"` + Platform string `json:"platform"` + AccountType string `json:"account_type,omitempty"` + Protocol string `json:"protocol"` + ChatSystemRoleOnly bool `json:"chat_system_role_only,omitempty"` + RequestProfile string `json:"request_profile,omitempty"` + Compact bool `json:"compact"` } type BusinessSystemPromptApplication struct { diff --git a/backend/internal/nativeapi/prompt_rules.go b/backend/internal/nativeapi/prompt_rules.go index 97370e3dad4e..a30f800993cc 100644 --- a/backend/internal/nativeapi/prompt_rules.go +++ b/backend/internal/nativeapi/prompt_rules.go @@ -1,16 +1,24 @@ package nativeapi +import "encoding/json" + // PromptRule references immutable template content. Account bindings contain // only rule IDs; customer history and account credentials are never policy RPCs. type PromptRule struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - TemplateID int64 `json:"template_id"` - VersionID int64 `json:"version_id"` + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + TemplateID int64 `json:"template_id"` + VersionID int64 `json:"version_id"` + Role string `json:"role"` + Platforms []string `json:"platforms"` + AccountTypes []string `json:"account_types,omitempty"` + ExcludeModelContains []string `json:"exclude_model_contains,omitempty"` + RequestProfiles []string `json:"request_profiles,omitempty"` + // Deprecated import fields. V2 rules always pin immutable content and use Role. FollowActive bool `json:"follow_active,omitempty"` Order int `json:"order"` - Delivery string `json:"delivery"` + Delivery string `json:"delivery,omitempty"` Position string `json:"position"` ModelMatch string `json:"model_match"` Models []string `json:"models"` @@ -28,23 +36,42 @@ type PromptAccountBinding struct { } type ResolvedPromptRule struct { - Rule PromptRule `json:"rule"` - Body string `json:"body"` - SHA256 string `json:"sha256"` - PreserveEcho bool `json:"preserve_echo,omitempty"` + Rule PromptRule `json:"rule"` + Body string `json:"body"` + SHA256 string `json:"sha256"` + PreserveEcho bool `json:"preserve_echo,omitempty"` + Unavailable bool `json:"unavailable,omitempty"` + ContentFormat string `json:"content_format,omitempty"` + StructuredContent json.RawMessage `json:"structured_content,omitempty"` } type PromptRulePlacement struct { - PreserveEcho bool `json:"preserve_echo,omitempty"` - RuleID string `json:"rule_id"` - TemplateID int64 `json:"template_id"` - VersionID int64 `json:"version_id"` - Delivery string `json:"delivery"` - Position string `json:"position"` - Carrier string `json:"carrier"` - Role string `json:"role,omitempty"` - Body string `json:"body"` - SHA256 string `json:"sha256"` + PreserveEcho bool `json:"preserve_echo,omitempty"` + RuleID string `json:"rule_id"` + TemplateID int64 `json:"template_id"` + VersionID int64 `json:"version_id"` + Delivery string `json:"delivery,omitempty"` + Protocol string `json:"protocol"` + Position string `json:"position"` + Carrier string `json:"carrier"` + Role string `json:"role,omitempty"` + Body string `json:"body"` + SHA256 string `json:"sha256"` + ContentFormat string `json:"content_format,omitempty"` + StructuredContent json.RawMessage `json:"structured_content,omitempty"` + Index *int `json:"index,omitempty"` + BlockIndex *int `json:"block_index,omitempty"` +} + +// PromptProtocolCapability is shared by the editor and policy validation. +// ConversationSystemModels is an explicit capability allowlist, not a model selector. +type PromptProtocolCapability struct { + Protocol string `json:"protocol"` + Platforms []string `json:"platforms"` + Roles []string `json:"roles"` + PositionsByRole map[string][]string `json:"positions_by_role"` + Limitations []string `json:"limitations"` + ConversationSystemModels []string `json:"conversation_system_models,omitempty"` } type PromptRuleDecision struct { @@ -59,13 +86,22 @@ type PromptRulesPlan struct { } const ( - PromptDeliveryNative = "native_control" - PromptDeliverySystem = "system" - PromptDeliveryDeveloper = "developer" - PromptPositionControlPrepend = "control_prepend" - PromptPositionControlAppend = "control_append" - PromptPositionConversationHead = "conversation_head" - PromptPositionConversationTail = "conversation_tail" - PromptRulesMaxCount = 32 - PromptRulesMaxBytes = 256 << 10 + PromptRulePolicyVersion = 2 + PromptRoleAuto = "auto" + PromptRoleSystem = "system" + PromptRoleDeveloper = "developer" + PromptContentAnthropicSystemBlocks = "anthropic_system_blocks" + PromptDeliveryNative = "native_control" + PromptDeliverySystem = "system" + PromptDeliveryDeveloper = "developer" + PromptPositionControlPrepend = "control_prepend" + PromptPositionControlAppend = "control_append" + PromptPositionConversationHead = "conversation_head" + PromptPositionConversationTail = "conversation_tail" + PromptPositionBeforeLastUser = "before_last_user" + PromptPositionAfterLastUser = "after_last_user" + // The configuration contains rules for different providers. Its capacity + // must accommodate the union of the former independent prompt domains. + PromptRulesMaxCount = 64 + PromptRulesMaxBytes = 256 << 10 ) diff --git a/backend/internal/promptskills/policy/management.go b/backend/internal/promptskills/policy/management.go index cb21ed4e9a0d..06f29bc3e047 100644 --- a/backend/internal/promptskills/policy/management.go +++ b/backend/internal/promptskills/policy/management.go @@ -28,7 +28,7 @@ func normalizeComposition(input extensionv1.PromptComposition) (extensionv1.Prom input.Mode = "inline" } switch input.Mode { - case "inline": + case "inline", extensionv1.PromptContentAnthropicSystemBlocks: if input.BundleID != "" || input.BundleManifestSHA256 != "" { return input, errors.New("inline composition cannot reference a bundle") } diff --git a/backend/internal/promptskills/policy/module.go b/backend/internal/promptskills/policy/module.go index d527204d8688..e534eb439f89 100644 --- a/backend/internal/promptskills/policy/module.go +++ b/backend/internal/promptskills/policy/module.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "strings" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" @@ -29,92 +28,8 @@ const ( var ErrBusinessSystemPromptUnavailable = errors.New("business system prompt unavailable") -func validateBusinessSystemPromptBodyWithLimit(body string, limit int) (string, int, error) { - return extensionv1.ValidateTextDocument(body, limit) -} -func Plan(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, hasInstructions bool) (BusinessSystemPromptApplication, error) { - if snapshot.RulePolicy != nil { - return planRules(snapshot, target) - } - bundleManifestSHA256 := snapshot.BundleManifestSHA256 - if snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { - bundleManifestSHA256 = snapshot.RegistryEffectiveTreeSHA256 - } - application := BusinessSystemPromptApplication{ - PreserveInstructionsEcho: snapshot.ExposeServerPrompt || snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid, - ExposeServerPrompt: snapshot.ExposeServerPrompt, - CompactEnabled: snapshot.CompactEnabled, - TemplateID: snapshot.TemplateID, - VersionID: snapshot.VersionID, - TemplateVersion: snapshot.TemplateVersion, - Revision: snapshot.Revision, - SHA256: strings.ToLower(strings.TrimSpace(snapshot.SHA256)), - BaseSHA256: strings.ToLower(strings.TrimSpace(snapshot.BaseSHA256)), - EffectiveSHA256: strings.ToLower(strings.TrimSpace(snapshot.EffectiveSHA256)), - EffectiveByteLength: snapshot.EffectiveByteLength, - CompositionMode: snapshot.CompositionMode, - BundleID: snapshot.BundleID, - BundleManifestSHA256: strings.ToLower(strings.TrimSpace(bundleManifestSHA256)), - BundleRevision: snapshot.RegistryRevision, - BundleRawTreeSHA256: strings.ToLower(strings.TrimSpace(snapshot.RegistryRawTreeSHA256)), - BundleEffectiveTreeSHA256: strings.ToLower(strings.TrimSpace(snapshot.RegistryEffectiveTreeSHA256)), - BundlePromptRawSHA256: strings.ToLower(strings.TrimSpace(snapshot.RegistryPromptRawSHA256)), - BundlePromptEffectiveSHA256: strings.ToLower(strings.TrimSpace(snapshot.RegistryPromptEffectiveSHA256)), - BundleUpstreamSourceID: snapshot.RegistryUpstreamSourceID, - BundleUpstreamRoot: snapshot.RegistryUpstreamRoot, - BundlePublicRoot: snapshot.RegistryPublicRoot, - Degraded: snapshot.Degraded, - } - if !snapshot.Enabled || target.Platform != PlatformOpenAI || (target.Compact && !snapshot.CompactEnabled) { - return application, nil - } - maxBytes := BusinessSystemPromptMaxBytes - if snapshot.EffectiveSHA256 != "" { - maxBytes = BusinessSystemPromptBundleMaxBytes - } - hash, byteLength, err := validateBusinessSystemPromptBodyWithLimit(snapshot.Body, maxBytes) - if err != nil { - return application, fmt.Errorf("%w: %v", ErrBusinessSystemPromptUnavailable, err) - } - expectedHash := snapshot.SHA256 - expectedLength := snapshot.ByteLength - if snapshot.EffectiveSHA256 != "" { - expectedHash = snapshot.EffectiveSHA256 - expectedLength = snapshot.EffectiveByteLength - } - if expectedHash != "" && !strings.EqualFold(expectedHash, hash) { - return application, fmt.Errorf("%w: snapshot hash mismatch", ErrBusinessSystemPromptUnavailable) - } - if expectedLength > 0 && expectedLength != byteLength { - return application, fmt.Errorf("%w: snapshot length mismatch", ErrBusinessSystemPromptUnavailable) - } - - application.Applied = true - application.ServerInstructions = strings.TrimSpace(snapshot.Body) - application.SHA256 = hash - if application.BaseSHA256 == "" { - application.BaseSHA256 = hash - } - if application.EffectiveSHA256 == "" { - application.EffectiveSHA256 = hash - } - if application.EffectiveByteLength == 0 { - application.EffectiveByteLength = byteLength - } - - switch target.Protocol { - case BusinessSystemPromptProtocolResponses: - application.Carrier = BusinessSystemPromptCarrierInstructions - case BusinessSystemPromptProtocolChat: - if hasInstructions { - application.Carrier = BusinessSystemPromptCarrierInstructions - } else { - application.Carrier = BusinessSystemPromptCarrierSystemMessage - } - default: - return BusinessSystemPromptApplication{}, fmt.Errorf("unsupported business system prompt protocol %q", target.Protocol) - } - return application, nil +func Plan(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, _ bool) (BusinessSystemPromptApplication, error) { + return PlanRules(snapshot, target) } type Module struct { diff --git a/backend/internal/promptskills/policy/module_test.go b/backend/internal/promptskills/policy/module_test.go index 03734a5fc0bf..df05496b4361 100644 --- a/backend/internal/promptskills/policy/module_test.go +++ b/backend/internal/promptskills/policy/module_test.go @@ -10,33 +10,29 @@ import ( ) func TestPromptPlanProtocolVisibilityAndIntegrity(t *testing.T) { - for _, tc := range []struct { - name, protocol string - compact, enabled, hasInstructions, applied bool - carrier string - }{ - {"responses", "responses", false, true, false, true, "instructions"}, - {"chat", "chat", false, true, false, true, "system_message"}, - {"chat instructions", "chat", false, true, true, true, "instructions"}, - {"compact disabled", "responses", true, true, false, false, ""}, - {"disabled", "responses", false, false, false, false, ""}, - } { - t.Run(tc.name, func(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{Enabled: tc.enabled, Body: "server prompt", ExposeServerPrompt: false} - raw, _ := json.Marshal(extensionv1.PromptPlanRequest{Snapshot: snapshot, Target: BusinessSystemPromptTarget{Platform: "openai", Protocol: tc.protocol, Compact: tc.compact}, HasInstructions: tc.hasInstructions}) - out, err := New().Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.plan", Payload: raw}) - if err != nil || out.Code != "" { - t.Fatalf("plan failed: %v %s", err, out.Code) - } - var result BusinessSystemPromptApplication - if json.Unmarshal(out.Payload, &result) != nil || result.Applied != tc.applied || result.Carrier != tc.carrier { - t.Fatalf("wrong plan: %s", out.Payload) - } - }) - } - if _, err := Plan(BusinessSystemPromptSnapshot{Enabled: true, Body: "server", SHA256: "wrong"}, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, true); err == nil { - t.Fatal("a changed prompt bypassed its stored digest") + for _, protocol := range []string{"responses", "chat"} { + snapshot := ruleSnapshot("auto", "control_append") + raw, err := json.Marshal(extensionv1.PromptPlanRequest{Snapshot: snapshot, Target: BusinessSystemPromptTarget{Platform: "openai", Protocol: protocol}, HasInstructions: true}) + require.NoError(t, err) + out, err := New().Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.plan", Payload: raw}) + require.NoError(t, err) + require.Empty(t, out.Code) + var result BusinessSystemPromptApplication + require.NoError(t, json.Unmarshal(out.Payload, &result)) + require.True(t, result.Applied) + require.Equal(t, "rule_plan", result.Carrier) + expectedCarrier := "instructions" + if protocol == "chat" { + expectedCarrier = "messages" + } + require.Equal(t, expectedCarrier, result.RulesPlan.Placements[0].Carrier) } + snapshot := ruleSnapshot("auto", "control_append") + snapshot.ResolvedRules[0].SHA256 = "wrong" + _, err := Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, true) + require.Error(t, err, "changed prompt must not bypass its stored digest") + _, err = Plan(BusinessSystemPromptSnapshot{Enabled: true, Body: "legacy"}, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, true) + require.Error(t, err, "single-prompt legacy snapshots must be migrated before execution") } func TestPromptPublicationPlanCarriesActionAndPreservesRollbackScope(t *testing.T) { diff --git a/backend/internal/promptskills/policy/rules.go b/backend/internal/promptskills/policy/rules.go index 57fa7de4fa84..69d60f78b6de 100644 --- a/backend/internal/promptskills/policy/rules.go +++ b/backend/internal/promptskills/policy/rules.go @@ -11,43 +11,127 @@ import ( "sort" "strings" + "github.com/Wei-Shaw/sub2api/internal/domain" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) var ruleIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) var ErrPromptDeliveryUnsupported = errors.New("prompt_delivery_unsupported") -func ValidateRulePolicy(input extensionv1.PromptRulePolicy) (extensionv1.PromptRulePolicy, error) { - if input.Version != 1 || len(input.Rules) > extensionv1.PromptRulesMaxCount { - return input, errors.New("invalid prompt rule policy version or rule count") +// PromptPlatforms describes the actual selected provider, not its wire protocol. +func PromptPlatforms() []string { + return []string{domain.PlatformOpenAI, domain.PlatformCindy, domain.PlatformAnthropic, domain.PlatformGemini, domain.PlatformAntigravity, domain.PlatformGrok, domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax, domain.PlatformOpenCodeGo} +} + +func PromptProtocolCapabilities() []extensionv1.PromptProtocolCapability { + all := []string{extensionv1.PromptPositionControlPrepend, extensionv1.PromptPositionControlAppend, extensionv1.PromptPositionConversationHead, extensionv1.PromptPositionConversationTail, extensionv1.PromptPositionBeforeLastUser, extensionv1.PromptPositionAfterLastUser} + control := slices.Clone(all[:2]) + claude := append(slices.Clone(control), extensionv1.PromptPositionConversationTail, extensionv1.PromptPositionAfterLastUser) + compatible := []string{domain.PlatformOpenAI, domain.PlatformCindy, domain.PlatformGrok, domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax, domain.PlatformOpenCodeGo} + roles := []string{extensionv1.PromptRoleAuto, extensionv1.PromptRoleSystem, extensionv1.PromptRoleDeveloper} + positions := func(allowedRoles, allowedPositions []string) map[string][]string { + result := make(map[string][]string, len(allowedRoles)) + for _, role := range allowedRoles { + result[role] = slices.Clone(allowedPositions) + } + return result } - if input.Rules == nil { - input.Rules = []extensionv1.PromptRule{} + return []extensionv1.PromptProtocolCapability{ + {Protocol: "responses", Platforms: slices.Clone(compatible), Roles: slices.Clone(roles), PositionsByRole: positions(roles, all), Limitations: []string{"codex_oauth_system_unsupported", "message_positions_visible_request_only", "missing_last_user_skips_rule", "unsafe_tool_boundary_skips_rule"}}, + {Protocol: "chat", Platforms: slices.Clone(compatible), Roles: slices.Clone(roles), PositionsByRole: positions(roles, all), Limitations: []string{"message_positions_visible_request_only", "missing_last_user_skips_rule", "unsafe_tool_boundary_skips_rule"}}, + {Protocol: "messages", Platforms: []string{domain.PlatformAnthropic, domain.PlatformAntigravity, domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax, domain.PlatformOpenCodeGo}, Roles: slices.Clone(roles[:2]), PositionsByRole: positions(roles[:2], claude), Limitations: []string{"conversation_system_requires_supported_upstream_model", "conversation_system_requires_user_then_assistant_or_end", "antigravity_messages_requires_upstream_account", "missing_last_user_skips_rule", "unsafe_tool_boundary_skips_rule"}, ConversationSystemModels: []string{"claude-fable-5-1", "claude-mythos-5-1", "claude-fable-5", "claude-mythos-5", "claude-opus-5-5", "claude-opus-4-8", "claude-opus-5"}}, + {Protocol: "gemini", Platforms: []string{domain.PlatformGemini, domain.PlatformAntigravity}, Roles: slices.Clone(roles[:2]), PositionsByRole: positions(roles[:2], control), Limitations: []string{"dedicated_system_instruction_only"}}, } - if input.DefaultRuleIDs == nil { - input.DefaultRuleIDs = []string{} +} + +func promptProtocolCapability(protocol string) (extensionv1.PromptProtocolCapability, bool) { + for _, capability := range PromptProtocolCapabilities() { + if capability.Protocol == protocol { + return capability, true + } + } + return extensionv1.PromptProtocolCapability{}, false +} + +func isControlPosition(position string) bool { + return position == extensionv1.PromptPositionControlPrepend || position == extensionv1.PromptPositionControlAppend +} + +// ClaudeConversationSystemSupported deliberately recognizes only published model +// IDs. Unknown aliases must first be resolved to the actual upstream model. +func ClaudeConversationSystemSupported(model string) bool { + capability, _ := promptProtocolCapability("messages") + return slices.Contains(capability.ConversationSystemModels, model) +} + +func ValidateRulePolicy(input extensionv1.PromptRulePolicy) (extensionv1.PromptRulePolicy, error) { + return validateRulePolicy(input, nil) +} + +// ValidateRulePolicyDraft permits only named, unsaved content references. It +// performs the same capability and scope checks before a transaction allocates IDs. +func ValidateRulePolicyDraft(input extensionv1.PromptRulePolicy, draftRuleIDs []string) (extensionv1.PromptRulePolicy, error) { + return validateRulePolicy(input, draftRuleIDs) +} + +func validateRulePolicy(input extensionv1.PromptRulePolicy, draftRuleIDs []string) (extensionv1.PromptRulePolicy, error) { + if input.Version != extensionv1.PromptRulePolicyVersion || len(input.Rules) > extensionv1.PromptRulesMaxCount { + return input, errors.New("invalid prompt rule policy version or rule count") } + // Normalization must not mutate an immutable request snapshot. + input.Rules = append([]extensionv1.PromptRule{}, input.Rules...) + input.DefaultRuleIDs = append([]string{}, input.DefaultRuleIDs...) seen := map[string]bool{} for i := range input.Rules { r := &input.Rules[i] - if r.Models == nil { - r.Models = []string{} - } + r.Models = append([]string{}, r.Models...) + r.Platforms = slices.Clone(r.Platforms) + r.AccountTypes = slices.Clone(r.AccountTypes) + r.ExcludeModelContains = slices.Clone(r.ExcludeModelContains) + r.RequestProfiles = slices.Clone(r.RequestProfiles) if !ruleIDPattern.MatchString(r.ID) || seen[r.ID] || strings.TrimSpace(r.Name) == "" || len(r.Name) > 200 { return input, errors.New("invalid or duplicate prompt rule") } seen[r.ID] = true - if !r.FollowActive && (r.TemplateID < 1 || r.VersionID < 1) { - return input, errors.New("rule requires an immutable template version") + if r.FollowActive { + return input, errors.New("v2 rules cannot follow the global active template") + } + if r.TemplateID < 1 || r.VersionID < 1 { + if !slices.Contains(draftRuleIDs, r.ID) || r.TemplateID != 0 || r.VersionID != 0 { + return input, errors.New("rule requires an immutable template version") + } } - if !slices.Contains([]string{extensionv1.PromptDeliveryNative, extensionv1.PromptDeliverySystem, extensionv1.PromptDeliveryDeveloper}, r.Delivery) { - return input, errors.New("invalid prompt delivery") + r.Delivery = "" + if !slices.Contains([]string{extensionv1.PromptRoleAuto, extensionv1.PromptRoleSystem, extensionv1.PromptRoleDeveloper}, r.Role) { + return input, errors.New("invalid prompt role") } - if !slices.Contains([]string{extensionv1.PromptPositionControlPrepend, extensionv1.PromptPositionControlAppend, extensionv1.PromptPositionConversationHead, extensionv1.PromptPositionConversationTail}, r.Position) { + generic, _ := promptProtocolCapability("responses") + if !slices.Contains(generic.PositionsByRole[r.Role], r.Position) { return input, errors.New("invalid prompt position") } - if r.Delivery == extensionv1.PromptDeliveryNative && strings.HasPrefix(r.Position, "conversation_") { - return input, fmt.Errorf("%w: native control has no conversation position", ErrPromptDeliveryUnsupported) + if len(r.Platforms) == 0 || validatePromptSelectors(r.Platforms, 32) != nil { + return input, errors.New("rule requires explicit provider platforms") + } + for _, platform := range r.Platforms { + if !slices.Contains(PromptPlatforms(), platform) { + return input, errors.New("unsupported prompt provider platform") + } + } + if validatePromptSelectors(r.AccountTypes, 16) != nil || validatePromptSelectors(r.RequestProfiles, 16) != nil || validatePromptSelectors(r.ExcludeModelContains, 128) != nil { + return input, errors.New("invalid prompt rule scope") + } + for _, accountType := range r.AccountTypes { + if !slices.Contains([]string{domain.AccountTypeOAuth, domain.AccountTypeSetupToken, domain.AccountTypeAPIKey, domain.AccountTypeUpstream, domain.AccountTypeBedrock, domain.AccountTypeServiceAccount}, accountType) { + return input, errors.New("unsupported prompt account type") + } + } + if r.Role == extensionv1.PromptRoleSystem && (slices.Contains(r.Platforms, domain.PlatformOpenAI) || slices.Contains(r.Platforms, domain.PlatformCindy)) && (slices.Contains(r.AccountTypes, domain.AccountTypeOAuth) || slices.Contains(r.AccountTypes, domain.AccountTypeSetupToken)) { + return input, fmt.Errorf("%w: explicit Codex OAuth scope cannot use system role", ErrPromptDeliveryUnsupported) + } + for _, profile := range r.RequestProfiles { + if profile != "generic-mimic" && profile != "claude-code" { + return input, errors.New("unsupported request profile") + } } if r.ModelMatch == "" { r.ModelMatch = "upstream" @@ -55,15 +139,45 @@ func ValidateRulePolicy(input extensionv1.PromptRulePolicy) (extensionv1.PromptR if r.ModelMatch != "upstream" && r.ModelMatch != "requested" { return input, errors.New("invalid model matching basis") } - if len(r.Models) > 128 { - return input, errors.New("too many model selectors") + if err := validatePromptSelectors(r.Models, 128); err != nil { + return input, err } - models := map[string]bool{} - for _, model := range r.Models { - if strings.TrimSpace(model) != model || model == "" || len(model) > 256 || models[model] { - return input, errors.New("invalid model selector") + // These platforms have a dedicated system carrier; the other providers + // select a final protocol dynamically and are checked again at dispatch. + for _, platform := range r.Platforms { + protocol := "" + switch platform { + case domain.PlatformAnthropic: + protocol = "messages" + case domain.PlatformGemini: + protocol = "gemini" + case domain.PlatformAntigravity: + protocol = "gemini" + // Antigravity passthrough accounts send native Messages. A + // broad account scope may select this supported destination; + // runtime planning still rejects incompatible OAuth targets. + // Explicit mixed types must satisfy both wire protocols. + if (len(r.AccountTypes) == 1 && r.AccountTypes[0] == domain.AccountTypeUpstream) || (len(r.AccountTypes) == 0 && !isControlPosition(r.Position)) { + protocol = "messages" + } + } + if protocol == "" { + continue + } + capability, _ := promptProtocolCapability(protocol) + if !slices.Contains(capability.PositionsByRole[r.Role], r.Position) { + return input, fmt.Errorf("%w: role or position is not supported by %s", ErrPromptDeliveryUnsupported, protocol) + } + if protocol == "messages" && !isControlPosition(r.Position) { + if r.ModelMatch != "upstream" || len(r.Models) == 0 { + return input, fmt.Errorf("%w: conversation system messages require explicit supported upstream models", ErrPromptDeliveryUnsupported) + } + for _, model := range r.Models { + if !ClaudeConversationSystemSupported(model) { + return input, fmt.Errorf("%w: model does not support conversation system messages", ErrPromptDeliveryUnsupported) + } + } } - models[model] = true } } selected := map[string]bool{} @@ -73,22 +187,35 @@ func ValidateRulePolicy(input extensionv1.PromptRulePolicy) (extensionv1.PromptR } selected[id] = true } - sort.SliceStable(input.Rules, func(i, j int) bool { - if input.Rules[i].Order == input.Rules[j].Order { - return input.Rules[i].ID < input.Rules[j].ID + for _, id := range draftRuleIDs { + if !seen[id] { + return input, errors.New("draft content references an unknown rule") } - return input.Rules[i].Order < input.Rules[j].Order - }) + } + // Equal order preserves the visible list order rather than sorting IDs. + sort.SliceStable(input.Rules, func(i, j int) bool { return input.Rules[i].Order < input.Rules[j].Order }) return input, nil } +func validatePromptSelectors(values []string, limit int) error { + if len(values) > limit { + return errors.New("too many prompt selectors") + } + seen := map[string]bool{} + for _, value := range values { + if strings.TrimSpace(value) != value || value == "" || len(value) > 256 || seen[value] { + return errors.New("invalid or duplicate prompt selector") + } + seen[value] = true + } + return nil +} + func ValidateAccountBinding(binding extensionv1.PromptAccountBinding, policy extensionv1.PromptRulePolicy) (extensionv1.PromptAccountBinding, error) { if binding.Mode == "" { binding.Mode = "inherit" } - if binding.RuleIDs == nil { - binding.RuleIDs = []string{} - } + binding.RuleIDs = append([]string{}, binding.RuleIDs...) if binding.Mode != "inherit" && binding.Mode != "off" && binding.Mode != "custom" { return binding, errors.New("invalid account prompt mode") } @@ -105,17 +232,31 @@ func ValidateAccountBinding(binding extensionv1.PromptAccountBinding, policy ext return binding, nil } -func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) (BusinessSystemPromptApplication, error) { +// PlanRules is the only executable prompt policy. Legacy data must be migrated +// to a pinned v2 rule before it can participate in forwarding. +func PlanRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) (BusinessSystemPromptApplication, error) { application := BusinessSystemPromptApplication{Revision: snapshot.Revision, ExposeServerPrompt: snapshot.ExposeServerPrompt, CompactEnabled: snapshot.CompactEnabled, RulesPlan: &extensionv1.PromptRulesPlan{Placements: []extensionv1.PromptRulePlacement{}, Skipped: []extensionv1.PromptRuleDecision{}}} - policy, err := ValidateRulePolicy(*snapshot.RulePolicy) + if snapshot.RulePolicy == nil { + if !snapshot.Enabled { + return FinishRulesPlan(application), nil + } + return application, fmt.Errorf("%w: v2 prompt policy is required", ErrBusinessSystemPromptUnavailable) + } + var draftRuleIDs []string + if snapshot.Draft { + for _, content := range snapshot.ResolvedRules { + if content.Rule.TemplateID == 0 && content.Rule.VersionID == 0 { + draftRuleIDs = append(draftRuleIDs, content.Rule.ID) + } + } + } + policy, err := validateRulePolicy(*snapshot.RulePolicy, draftRuleIDs) if err != nil { return application, err } scopeReason := "" if !snapshot.Enabled { scopeReason = "global_disabled" - } else if target.Platform != PlatformOpenAI { - scopeReason = "platform_excluded" } else if target.Compact && !snapshot.CompactEnabled { scopeReason = "compact_disabled" } @@ -123,7 +264,7 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp for _, rule := range policy.Rules { application.RulesPlan.Skipped = append(application.RulesPlan.Skipped, extensionv1.PromptRuleDecision{RuleID: rule.ID, Reason: scopeReason}) } - return finishRulesPlan(application, 0), nil + return FinishRulesPlan(application), nil } binding := extensionv1.PromptAccountBinding{Mode: "inherit"} if target.BindingJSON != "" && json.Unmarshal([]byte(target.BindingJSON), &binding) != nil { @@ -142,22 +283,29 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp } resolved := map[string]extensionv1.ResolvedPromptRule{} for _, rule := range snapshot.ResolvedRules { + if _, duplicate := resolved[rule.Rule.ID]; duplicate { + return application, fmt.Errorf("%w: duplicate resolved rule", ErrBusinessSystemPromptUnavailable) + } resolved[rule.Rule.ID] = rule } + platform := target.ProviderPlatform + if platform == "" { + platform = target.Platform + } total := 0 for _, rule := range policy.Rules { reason := "" switch { - case !snapshot.Enabled: - reason = "global_disabled" - case target.Platform != PlatformOpenAI: - reason = "platform_excluded" - case target.Compact && !snapshot.CompactEnabled: - reason = "compact_disabled" case !slices.Contains(selected, rule.ID): reason = "account_scope" case !rule.Enabled: reason = "rule_disabled" + case !slices.Contains(rule.Platforms, platform): + reason = "platform_scope" + case len(rule.AccountTypes) > 0 && !slices.Contains(rule.AccountTypes, target.AccountType): + reason = "account_type_scope" + case len(rule.RequestProfiles) > 0 && !slices.Contains(rule.RequestProfiles, target.RequestProfile): + reason = "request_profile_scope" } model := target.UpstreamModel if rule.ModelMatch == "requested" { @@ -166,13 +314,25 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp if reason == "" && len(rule.Models) > 0 && !slices.Contains(rule.Models, model) { reason = "model_scope" } + if reason == "" && slices.ContainsFunc(rule.ExcludeModelContains, func(part string) bool { + return strings.Contains(strings.ToLower(target.UpstreamModel), strings.ToLower(part)) + }) { + reason = "model_scope" + } if reason != "" { application.RulesPlan.Skipped = append(application.RulesPlan.Skipped, extensionv1.PromptRuleDecision{RuleID: rule.ID, Reason: reason}) continue } + placement, err := planPromptPlacement(rule, target, platform) + if err != nil { + return application, err + } content, ok := resolved[rule.ID] - if !ok { - return application, fmt.Errorf("%w: unresolved template version", ErrBusinessSystemPromptUnavailable) + if !ok || content.Rule.TemplateID != rule.TemplateID || content.Rule.VersionID != rule.VersionID { + return application, fmt.Errorf("%w: unresolved immutable template version", ErrBusinessSystemPromptUnavailable) + } + if content.Unavailable { + return application, fmt.Errorf("%w: selected prompt source is unavailable", ErrBusinessSystemPromptUnavailable) } hash, size, validationErr := extensionv1.ValidateTextDocument(content.Body, extensionv1.PromptRulesMaxBytes) if validationErr != nil || hash != content.SHA256 { @@ -182,28 +342,81 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp if total > extensionv1.PromptRulesMaxBytes { return application, errors.New("compiled prompt rules exceed limit") } - placement := extensionv1.PromptRulePlacement{RuleID: rule.ID, TemplateID: content.Rule.TemplateID, VersionID: content.Rule.VersionID, Delivery: rule.Delivery, Position: rule.Position, Body: strings.TrimSpace(content.Body), SHA256: hash, PreserveEcho: content.PreserveEcho} - switch target.Protocol { - case BusinessSystemPromptProtocolResponses: - if rule.Delivery == extensionv1.PromptDeliveryNative { - placement.Carrier = "instructions" - } else { - placement.Carrier, placement.Role = "input", rule.Delivery + placement.Body, placement.SHA256, placement.PreserveEcho = content.Body, hash, content.PreserveEcho + placement.ContentFormat = content.ContentFormat + if content.ContentFormat != "" && content.ContentFormat != "text" { + if content.ContentFormat != extensionv1.PromptContentAnthropicSystemBlocks || placement.Carrier != "system" || target.Protocol != "messages" { + return application, fmt.Errorf("%w: structured content requires the Claude system carrier", ErrPromptDeliveryUnsupported) } - case BusinessSystemPromptProtocolChat: - placement.Carrier, placement.Role = "messages", rule.Delivery - if rule.Delivery == extensionv1.PromptDeliveryNative { - placement.Role = "system" + if !json.Valid(content.StructuredContent) || !jsonEqualPromptContent(content.Body, content.StructuredContent) { + return application, fmt.Errorf("%w: structured content digest mismatch", ErrBusinessSystemPromptUnavailable) } - default: - return application, fmt.Errorf("%w: protocol does not have a supported control carrier", ErrPromptDeliveryUnsupported) - } - if (target.AccountType == "oauth" || target.AccountType == "setup-token") && rule.Delivery == extensionv1.PromptDeliverySystem { - return application, fmt.Errorf("%w: Codex OAuth requires native instructions or developer messages", ErrPromptDeliveryUnsupported) + placement.StructuredContent = slices.Clone(content.StructuredContent) } application.RulesPlan.Placements = append(application.RulesPlan.Placements, placement) } - application.PreserveInstructionsEcho = application.PreserveInstructionsEcho || snapshot.ExposeServerPrompt + return FinishRulesPlan(application), nil +} + +func jsonEqualPromptContent(body string, structured json.RawMessage) bool { + var first, second any + if json.Unmarshal([]byte(body), &first) != nil || json.Unmarshal(structured, &second) != nil { + return false + } + a, _ := json.Marshal(first) + b, _ := json.Marshal(second) + return string(a) == string(b) +} + +func planPromptPlacement(rule extensionv1.PromptRule, target BusinessSystemPromptTarget, platform string) (extensionv1.PromptRulePlacement, error) { + placement := extensionv1.PromptRulePlacement{RuleID: rule.ID, TemplateID: rule.TemplateID, VersionID: rule.VersionID, Position: rule.Position, Protocol: target.Protocol, Role: rule.Role} + capability, ok := promptProtocolCapability(target.Protocol) + if !ok || !slices.Contains(capability.PositionsByRole[rule.Role], rule.Position) { + return placement, fmt.Errorf("%w: protocol cannot express role or position", ErrPromptDeliveryUnsupported) + } + switch target.Protocol { + case "responses": + if rule.Role == extensionv1.PromptRoleAuto && isControlPosition(rule.Position) { + placement.Carrier, placement.Role = "instructions", "system" + } else { + placement.Carrier = "input" + if rule.Role == extensionv1.PromptRoleAuto { + placement.Role = "developer" + } + } + case "chat": + if target.ChatSystemRoleOnly && rule.Role == extensionv1.PromptRoleDeveloper { + return placement, fmt.Errorf("%w: this Chat destination requires system messages", ErrPromptDeliveryUnsupported) + } + placement.Carrier = "messages" + if rule.Role == extensionv1.PromptRoleAuto { + placement.Role = "system" + } + case "messages": + placement.Role, placement.Carrier = "system", "system" + if !isControlPosition(rule.Position) { + if !ClaudeConversationSystemSupported(target.UpstreamModel) { + return placement, fmt.Errorf("%w: upstream model cannot accept conversation system messages", ErrPromptDeliveryUnsupported) + } + placement.Carrier = "messages" + } + case "gemini": + placement.Role, placement.Carrier = "system", "systemInstruction" + } + if (platform == domain.PlatformOpenAI || platform == domain.PlatformCindy) && (target.AccountType == domain.AccountTypeOAuth || target.AccountType == domain.AccountTypeSetupToken) && rule.Role == extensionv1.PromptRoleSystem { + return placement, fmt.Errorf("%w: Codex OAuth requires auto or developer role", ErrPromptDeliveryUnsupported) + } + return placement, nil +} + +// FinishRulesPlan is called again after anchor validation, so cache identities +// and echo behavior describe only insertions that actually reached the wire. +func FinishRulesPlan(application BusinessSystemPromptApplication) BusinessSystemPromptApplication { + if application.RulesPlan == nil { + return application + } + application.EffectiveByteLength = 0 + application.PreserveInstructionsEcho = application.ExposeServerPrompt if len(application.RulesPlan.Placements) > 0 && !application.PreserveInstructionsEcho { application.PreserveInstructionsEcho = true for _, placement := range application.RulesPlan.Placements { @@ -213,16 +426,19 @@ func planRules(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromp } } } - return finishRulesPlan(application, total), nil -} - -func finishRulesPlan(application BusinessSystemPromptApplication, total int) BusinessSystemPromptApplication { - raw, _ := json.Marshal(application.RulesPlan.Placements) + for _, placement := range application.RulesPlan.Placements { + application.EffectiveByteLength += len(placement.Body) + } + // Runtime indices intentionally do not alter the semantic prompt identity. + placements := slices.Clone(application.RulesPlan.Placements) + for i := range placements { + placements[i].Index, placements[i].BlockIndex = nil, nil + } + raw, _ := json.Marshal(placements) digest := sha256.Sum256(raw) application.RulesPlan.SHA256 = hex.EncodeToString(digest[:]) - application.Applied = len(application.RulesPlan.Placements) > 0 + application.Applied = len(placements) > 0 application.SHA256, application.EffectiveSHA256 = application.RulesPlan.SHA256, application.RulesPlan.SHA256 - application.EffectiveByteLength = total application.Carrier = "rule_plan" return application } diff --git a/backend/internal/promptskills/policy/rules_test.go b/backend/internal/promptskills/policy/rules_test.go index 1ede39148efb..81cbd70c7777 100644 --- a/backend/internal/promptskills/policy/rules_test.go +++ b/backend/internal/promptskills/policy/rules_test.go @@ -2,35 +2,77 @@ package policy import ( "errors" + "reflect" + "slices" "testing" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -func ruleSnapshot(delivery, position string) BusinessSystemPromptSnapshot { - rule := extensionv1.PromptRule{ID: "rule", Name: "Rule", Enabled: true, TemplateID: 1, VersionID: 2, Delivery: delivery, Position: position, ModelMatch: "upstream"} +func ruleSnapshot(role, position string) BusinessSystemPromptSnapshot { + rule := extensionv1.PromptRule{ID: "rule", Name: "Rule", Enabled: true, TemplateID: 1, VersionID: 2, Role: role, Platforms: []string{"openai"}, Position: position, ModelMatch: "upstream"} hash, _, _ := extensionv1.ValidateTextDocument("site", 100) - return BusinessSystemPromptSnapshot{Enabled: true, Revision: 1, RulePolicy: &extensionv1.PromptRulePolicy{Version: 1, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"rule"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site", SHA256: hash}}} + return BusinessSystemPromptSnapshot{Enabled: true, Revision: 1, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"rule"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site", SHA256: hash}}} } -func TestPromptRulesDeliveryPositionMatrix(t *testing.T) { - for _, delivery := range []string{"native_control", "system", "developer"} { - for _, position := range []string{"control_prepend", "control_append", "conversation_head", "conversation_tail"} { - for _, protocol := range []string{"responses", "chat"} { - t.Run(delivery+"/"+position+"/"+protocol, func(t *testing.T) { - application, err := Plan(ruleSnapshot(delivery, position), BusinessSystemPromptTarget{Platform: "openai", AccountType: "apikey", Protocol: protocol}, true) - if delivery == "native_control" && (position == "conversation_head" || position == "conversation_tail") { +func TestPromptRulesV2RolePositionProtocolMatrix(t *testing.T) { + all, _ := promptProtocolCapability("responses") + for _, role := range all.Roles { + for _, position := range all.PositionsByRole[role] { + for _, capability := range PromptProtocolCapabilities() { + t.Run(role+"/"+position+"/"+capability.Protocol, func(t *testing.T) { + snapshot := ruleSnapshot(role, position) + platform := "openai" + if capability.Protocol == "messages" { + platform = "anthropic" + } + if capability.Protocol == "gemini" { + platform = "gemini" + } + snapshot.RulePolicy.Rules[0].Platforms = []string{platform} + snapshot.RulePolicy.Rules[0].Models = []string{"claude-opus-4-8"} + target := BusinessSystemPromptTarget{Platform: platform, AccountType: "apikey", Protocol: capability.Protocol, UpstreamModel: "claude-opus-4-8"} + application, err := Plan(snapshot, target, true) + allowed := false + for _, p := range capability.PositionsByRole[role] { + if p == position { + allowed = true + } + } + if !allowed { if !errors.Is(err, ErrPromptDeliveryUnsupported) { - t.Fatalf("expected explicit unsupported error, got %v", err) + t.Fatalf("expected unsupported, got %v", err) } return } if err != nil || !application.Applied { - t.Fatalf("expected plan: %+v, %v", application, err) + t.Fatalf("expected application, got %+v, %v", application, err) } placement := application.RulesPlan.Placements[0] - if protocol == "chat" && (placement.Carrier != "messages" || placement.Role == "native_control") { - t.Fatal("Chat must use a real role even if instructions exists") + if placement.Protocol != capability.Protocol { + t.Fatal("missing final protocol") + } + switch capability.Protocol { + case "responses": + if role == "auto" && isControlPosition(position) { + if placement.Carrier != "instructions" || placement.Role != "system" { + t.Fatal(placement) + } + } else if placement.Carrier != "input" || placement.Role == "auto" { + t.Fatal(placement) + } + case "chat": + if placement.Carrier != "messages" || placement.Role == "auto" { + t.Fatal(placement) + } + case "messages": + if placement.Role != "system" { + t.Fatal(placement) + } + case "gemini": + if placement.Carrier != "systemInstruction" || placement.Role != "system" { + t.Fatal(placement) + } } }) } @@ -38,7 +80,7 @@ func TestPromptRulesDeliveryPositionMatrix(t *testing.T) { } } -func TestPromptRulesAccountModelAndOAuthScope(t *testing.T) { +func TestPromptRulesV2AccountModelProviderAndOAuthScope(t *testing.T) { snapshot := ruleSnapshot("system", "conversation_tail") target := BusinessSystemPromptTarget{Platform: "openai", AccountType: "oauth", Protocol: "responses"} if _, err := Plan(snapshot, target, false); !errors.Is(err, ErrPromptDeliveryUnsupported) { @@ -46,7 +88,7 @@ func TestPromptRulesAccountModelAndOAuthScope(t *testing.T) { } target.BindingJSON = `{"mode":"off","rule_ids":[]}` if result, err := Plan(snapshot, target, false); err != nil || result.Applied { - t.Fatal("off account must bypass incompatible rules", err) + t.Fatal("off must bypass incompatible targets", err) } target.BindingJSON, target.AccountType = "", "apikey" snapshot.RulePolicy.Rules[0].Models = []string{"mapped"} @@ -58,22 +100,47 @@ func TestPromptRulesAccountModelAndOAuthScope(t *testing.T) { if result, err := Plan(snapshot, target, false); err != nil || !result.Applied { t.Fatal(err) } + target.ProviderPlatform = "cindy" + if result, err := Plan(snapshot, target, false); err != nil || result.Applied || result.RulesPlan.Skipped[0].Reason != "platform_scope" { + t.Fatal("wire OpenAI must not erase real Cindy scope", err) + } + snapshot.RulePolicy.Rules[0].Platforms = []string{"cindy"} + snapshot.RulePolicy.Rules[0].AccountTypes = []string{"apikey"} + snapshot.RulePolicy.Rules[0].RequestProfiles = []string{"generic-mimic"} + target.RequestProfile = "claude-code" + if result, err := Plan(snapshot, target, false); err != nil || result.Applied || result.RulesPlan.Skipped[0].Reason != "request_profile_scope" { + t.Fatal(err) + } + target.RequestProfile = "generic-mimic" + snapshot.RulePolicy.Rules[0].Models = nil + snapshot.RulePolicy.Rules[0].ExcludeModelContains = []string{"fable"} + target.UpstreamModel = "provider/Claude-Fable-5" + if result, err := Plan(snapshot, target, false); err != nil || result.Applied || result.RulesPlan.Skipped[0].Reason != "model_scope" { + t.Fatal("old Fable family exclusion changed", err) + } } -func TestPromptRulesDisabledDoesNotResolveInvalidAccountOrContent(t *testing.T) { +func TestPromptRulesV2DisabledAndCustomSelection(t *testing.T) { snapshot := ruleSnapshot("system", "conversation_tail") snapshot.Enabled, snapshot.ResolvedRules = false, nil application, err := Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", AccountType: "oauth", Protocol: "responses", BindingJSON: "malformed-old-binding"}, false) - if err != nil || application.Applied { - t.Fatalf("disabled policy must not affect forwarding: %v", err) + if err != nil || application.Applied || application.RulesPlan.Skipped[0].Reason != "global_disabled" { + t.Fatalf("disabled policy affected forwarding: %v", err) } - if application.RulesPlan.Skipped[0].Reason != "global_disabled" { - t.Fatal("missing explicit skip reason") + snapshot = ruleSnapshot("auto", "control_append") + second := snapshot.RulePolicy.Rules[0] + second.ID, second.Name = "custom", "Custom" + snapshot.RulePolicy.Rules = append(snapshot.RulePolicy.Rules, second) + hash, _, _ := extensionv1.ValidateTextDocument("custom", 100) + snapshot.ResolvedRules = append(snapshot.ResolvedRules, extensionv1.ResolvedPromptRule{Rule: second, Body: "custom", SHA256: hash}) + application, err = Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses", BindingJSON: `{"mode":"custom","rule_ids":["custom"]}`}, false) + if err != nil || len(application.RulesPlan.Placements) != 1 || application.RulesPlan.Placements[0].RuleID != "custom" { + t.Fatal("custom selection must replace defaults", err) } } -func TestPromptRulesPublicEchoDoesNotExposePrivateSibling(t *testing.T) { - snapshot := ruleSnapshot("native_control", "control_append") +func TestPromptRulesV2PublicEchoDoesNotExposePrivateSibling(t *testing.T) { + snapshot := ruleSnapshot("auto", "control_append") snapshot.ResolvedRules[0].PreserveEcho = true privateRule := snapshot.RulePolicy.Rules[0] privateRule.ID, privateRule.Name = "private", "Private rule" @@ -86,15 +153,162 @@ func TestPromptRulesPublicEchoDoesNotExposePrivateSibling(t *testing.T) { t.Fatal(err) } if application.PreserveInstructionsEcho { - t.Fatal("one public skill must not expose a private sibling rule") + t.Fatal("a public rule exposed a private sibling") + } + if !application.RulesPlan.Placements[0].PreserveEcho || application.RulesPlan.Placements[1].PreserveEcho { + t.Fatal("individual echo scopes changed") } - public := 0 - for _, placement := range application.RulesPlan.Placements { - if placement.PreserveEcho { - public++ +} + +func TestPromptRulesV2ValidationDraftAndImmutableOrder(t *testing.T) { + snapshot := ruleSnapshot("auto", "control_append") + policy := *snapshot.RulePolicy + rule := policy.Rules[0] + rule.ID, rule.Name = "alpha", "Alpha" + policy.Rules = append(policy.Rules, rule) + normalized, err := ValidateRulePolicy(policy) + if err != nil || normalized.Rules[0].ID != "rule" { + t.Fatal("equal order must retain list order", err) + } + normalized.Rules[0].Platforms[0] = "gemini" + if policy.Rules[0].Platforms[0] != "openai" { + t.Fatal("validation mutated input") + } + policy.Rules[0].TemplateID, policy.Rules[0].VersionID = 0, 0 + if _, err := ValidateRulePolicy(policy); err == nil { + t.Fatal("live policy accepted draft refs") + } + if _, err := ValidateRulePolicyDraft(policy, []string{"rule"}); err != nil { + t.Fatal(err) + } + policy.Rules[0].TemplateID, policy.Rules[0].VersionID = 1, 2 + policy.Rules[0].FollowActive = true + if _, err := ValidateRulePolicy(policy); err == nil { + t.Fatal("follow_active remained executable") + } + policy.Rules[0].FollowActive = false + policy.Rules[0].Role, policy.Rules[0].Delivery = "", "system" + if _, err := ValidateRulePolicy(policy); err == nil { + t.Fatal("delivery silently supplied v2 role") + } + policy.Rules[0].Role, policy.Rules[0].Platforms = "auto", nil + if _, err := ValidateRulePolicy(policy); err == nil { + t.Fatal("empty scope expanded to all platforms") + } + if !reflect.DeepEqual(snapshot.RulePolicy.DefaultRuleIDs, []string{"rule"}) { + t.Fatal("snapshot selectors changed") + } +} + +func TestPromptRulesV2ClaudeModelCapabilityAndContentIntegrity(t *testing.T) { + snapshot := ruleSnapshot("system", "after_last_user") + snapshot.RulePolicy.Rules[0].Platforms = []string{"anthropic"} + snapshot.RulePolicy.Rules[0].Models = []string{"claude-opus-4-8"} + target := BusinessSystemPromptTarget{Platform: "anthropic", Protocol: "messages", UpstreamModel: "claude-opus-4-8"} + if result, err := Plan(snapshot, target, false); err != nil || !result.Applied { + t.Fatal(err) + } + snapshot.RulePolicy.Rules[0].Models = []string{"claude-sonnet-5"} + if _, err := ValidateRulePolicy(*snapshot.RulePolicy); !errors.Is(err, ErrPromptDeliveryUnsupported) { + t.Fatal("unsupported Claude model accepted", err) + } + snapshot = ruleSnapshot("auto", "control_append") + snapshot.ResolvedRules[0].Rule.VersionID++ + if _, err := Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, false); !errors.Is(err, ErrBusinessSystemPromptUnavailable) { + t.Fatal("resolved content was not pinned", err) + } + snapshot = ruleSnapshot("auto", "control_append") + snapshot.ResolvedRules[0].Body = "tampered" + if _, err := Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, false); !errors.Is(err, ErrBusinessSystemPromptUnavailable) { + t.Fatal("changed content bypassed digest", err) + } + snapshot = ruleSnapshot("auto", "control_append") + snapshot.RulePolicy.Version = 1 + if _, err := Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}, false); err == nil { + t.Fatal("legacy policy remained executable") + } +} + +func TestPromptRulesV2UnavailableSourceOnlyBlocksSelectedRule(t *testing.T) { + snapshot := ruleSnapshot("auto", "control_append") + source := snapshot.RulePolicy.Rules[0] + source.ID, source.Name = "optional-source", "Optional source" + source.Models = []string{"source-model"} + snapshot.RulePolicy.Rules = append(snapshot.RulePolicy.Rules, source) + snapshot.RulePolicy.DefaultRuleIDs = append(snapshot.RulePolicy.DefaultRuleIDs, source.ID) + snapshot.ResolvedRules = append(snapshot.ResolvedRules, extensionv1.ResolvedPromptRule{Rule: source, Unavailable: true}) + target := BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses", UpstreamModel: "inline-model"} + application, err := PlanRules(snapshot, target) + if err != nil || !application.Applied || len(application.RulesPlan.Placements) != 1 || application.RulesPlan.Placements[0].RuleID != "rule" { + t.Fatalf("unselected source blocked inline content: %+v, %v", application, err) + } + if len(application.RulesPlan.Skipped) != 1 || application.RulesPlan.Skipped[0].Reason != "model_scope" { + t.Fatalf("unselected source lost its scope decision: %+v", application.RulesPlan.Skipped) + } + target.UpstreamModel = "source-model" + if _, err := PlanRules(snapshot, target); !errors.Is(err, ErrBusinessSystemPromptUnavailable) { + t.Fatalf("selected unavailable source did not fail explicitly: %v", err) + } + target.BindingJSON = `{"mode":"off","rule_ids":[]}` + if application, err := PlanRules(snapshot, target); err != nil || application.Applied { + t.Fatalf("off account was blocked by unavailable source: %+v, %v", application, err) + } +} + +func TestPromptRulesV2AntigravityUpstreamMessagesCapabilities(t *testing.T) { + snapshot := ruleSnapshot("system", "after_last_user") + rule := &snapshot.RulePolicy.Rules[0] + rule.Platforms = []string{"antigravity"} + rule.AccountTypes = []string{"upstream"} + rule.Models = []string{"claude-opus-4-8"} + capability, ok := promptProtocolCapability("messages") + if !ok || !slices.Contains(capability.Platforms, "antigravity") { + t.Fatal("Antigravity native Messages capability is missing") + } + target := BusinessSystemPromptTarget{Platform: "antigravity", AccountType: "upstream", Protocol: "messages", UpstreamModel: "claude-opus-4-8"} + application, err := PlanRules(snapshot, target) + if err != nil || !application.Applied || application.RulesPlan.Placements[0].Carrier != "messages" || application.RulesPlan.Placements[0].Role != "system" { + t.Fatalf("upstream account lost supported Messages placement: %+v, %v", application, err) + } + rule.AccountTypes = nil + if _, err := ValidateRulePolicy(*snapshot.RulePolicy); err != nil { + t.Fatalf("unspecified account scope cannot select its supported Messages target: %v", err) + } + if _, err := PlanRules(snapshot, BusinessSystemPromptTarget{Platform: "antigravity", AccountType: "oauth", Protocol: "gemini", UpstreamModel: "claude-opus-4-8"}); !errors.Is(err, ErrPromptDeliveryUnsupported) { + t.Fatalf("runtime Gemini target must reject conversation position: %v", err) + } + for _, accountTypes := range [][]string{{"oauth"}, {"upstream", "oauth"}} { + rule.AccountTypes = accountTypes + if _, err := ValidateRulePolicy(*snapshot.RulePolicy); !errors.Is(err, ErrPromptDeliveryUnsupported) { + t.Fatalf("explicit non-Messages scope accepted unsupported conversation position %v: %v", accountTypes, err) } } - if public != 1 { - t.Fatalf("expected one individually public rule, got %d", public) + rule.Position = "control_append" + if _, err := ValidateRulePolicy(*snapshot.RulePolicy); err != nil { + t.Fatalf("mixed Antigravity types lost their common system carrier: %v", err) + } +} + +func TestPromptRulesV2StrictChatDeveloperRole(t *testing.T) { + snapshot := ruleSnapshot("developer", "control_append") + target := BusinessSystemPromptTarget{Platform: "openai", Protocol: "chat", ChatSystemRoleOnly: true} + if _, err := PlanRules(snapshot, target); !errors.Is(err, ErrPromptDeliveryUnsupported) { + t.Fatalf("strict Chat destination accepted developer role: %v", err) + } + target.Protocol = "responses" + application, err := PlanRules(snapshot, target) + if err != nil || !application.Applied || application.RulesPlan.Placements[0].Role != "developer" { + t.Fatalf("Chat-only restriction affected Responses: %+v, %v", application, err) + } + target.Protocol, target.ChatSystemRoleOnly = "chat", false + application, err = PlanRules(snapshot, target) + if err != nil || !application.Applied || application.RulesPlan.Placements[0].Role != "developer" { + t.Fatalf("ordinary Chat developer role was changed: %+v, %v", application, err) + } + snapshot.RulePolicy.Rules[0].Role = "auto" + target.ChatSystemRoleOnly = true + application, err = PlanRules(snapshot, target) + if err != nil || !application.Applied || application.RulesPlan.Placements[0].Role != "system" { + t.Fatalf("strict Chat auto role lost its system carrier: %+v, %v", application, err) } } diff --git a/backend/internal/repository/business_system_prompt_config.go b/backend/internal/repository/business_system_prompt_config.go new file mode 100644 index 000000000000..d1970af953dd --- /dev/null +++ b/backend/internal/repository/business_system_prompt_config.go @@ -0,0 +1,205 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// SavePromptConfig serializes both content edits and rule selection against the +// same runtime row used by account bindings. A reader therefore sees either the +// entire previous configuration or the entire new configuration. +func (r *businessSystemPromptRepository) SavePromptConfig(ctx context.Context, input service.PromptConfigUpdate, actorID int64) error { + if r == nil || r.db == nil { + return service.ErrBusinessSystemPromptUnavailable + } + if input.Policy.Version != nativeapi.PromptRulePolicyVersion || len(input.Policy.Rules) > nativeapi.PromptRulesMaxCount { + return service.ErrBusinessSystemPromptInvalid + } + policy := input.Policy + policy.Rules = append([]nativeapi.PromptRule{}, input.Policy.Rules...) + draftIDs := make([]string, 0, len(input.Contents)) + for id := range input.Contents { + draftIDs = append(draftIDs, id) + } + var err error + policy, err = promptpolicy.ValidateRulePolicyDraft(policy, draftIDs) + if err != nil { + return fmt.Errorf("%w: %v", service.ErrBusinessSystemPromptInvalid, err) + } + seen := make(map[string]bool, len(policy.Rules)) + for _, rule := range policy.Rules { + if rule.ID == "" || seen[rule.ID] || rule.FollowActive || rule.Delivery != "" { + return service.ErrBusinessSystemPromptInvalid + } + seen[rule.ID] = true + } + for id, draft := range input.Contents { + if !seen[id] { + return fmt.Errorf("%w: content refers to an unknown rule", service.ErrBusinessSystemPromptInvalid) + } + if _, _, err := service.ValidateBusinessSystemPromptBody(draft.Body); err != nil { + return err + } + } + for _, id := range policy.DefaultRuleIDs { + if !seen[id] { + return fmt.Errorf("%w: default refers to an unknown rule", service.ErrBusinessSystemPromptInvalid) + } + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := lockBusinessSystemPromptRuntimeRevision(ctx, tx, input.ExpectedRevision); err != nil { + return err + } + var previousRaw []byte + if err := tx.QueryRowContext(ctx, `SELECT policy FROM system_prompt_rule_policies WHERE id = 1`).Scan(&previousRaw); err != nil { + return err + } + var previous nativeapi.PromptRulePolicy + if err := json.Unmarshal(previousRaw, &previous); err != nil || previous.Version != nativeapi.PromptRulePolicyVersion { + return service.ErrBusinessSystemPromptUnavailable + } + previousRules := make(map[string]nativeapi.PromptRule, len(previous.Rules)) + for _, rule := range previous.Rules { + previousRules[rule.ID] = rule + } + if err := checkPromptConfigAccountReferences(ctx, tx, policy); err != nil { + return err + } + + 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 { + return fmt.Errorf("%w: an existing rule cannot change its content source", 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) + VALUES ($1, $2, $3, $3) RETURNING id`, "prompt-"+uuid.NewString(), strings.TrimSpace(rule.Name), nullableActor(actorID)).Scan(&rule.TemplateID); err != nil { + return translateBusinessSystemPromptWriteError(err) + } + version = promptConfigStoredVersion{compositionMode: service.BusinessSystemPromptCompositionInline} + } else { + if rule.TemplateID < 1 || rule.VersionID < 1 { + return service.ErrBusinessSystemPromptVersionNotFound + } + version, err = loadPromptConfigVersion(ctx, tx, rule.TemplateID, rule.VersionID) + if err != nil { + return err + } + if err := validateStoredBusinessSystemPromptVersion(version.body, version.sha256, version.byteLength, + version.compositionMode, version.bundleID, version.bundleManifestSHA256); err != nil { + return err + } + if hasDraft && (version.managedSource != "" || version.compositionMode != service.BusinessSystemPromptCompositionInline) { + return fmt.Errorf("%w: managed prompt content requires an independent copy", service.ErrBusinessSystemPromptSourceNotManaged) + } + } + if hasDraft { + version.body = draft.Body + version.sha256, version.byteLength, err = service.ValidateBusinessSystemPromptBody(draft.Body) + if err != nil { + return err + } + var latest int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM system_prompt_template_versions WHERE template_id = $1`, rule.TemplateID).Scan(&latest); err != nil { + return err + } + if err := tx.QueryRowContext(ctx, `INSERT INTO system_prompt_template_versions + (template_id, version, body, sha256, byte_length, composition_mode, bundle_id, bundle_manifest_sha256, note, created_by, published_at, published_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, NOW(), $9) RETURNING id`, + rule.TemplateID, latest+1, version.body, version.sha256, version.byteLength, version.compositionMode, + nullableString(version.bundleID), nullableString(version.bundleManifestSHA256), nullableActor(actorID)).Scan(&rule.VersionID); err != nil { + return translateBusinessSystemPromptWriteError(err) + } + } + if !hasDraft { + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_template_versions + SET published_at = COALESCE(published_at, NOW()), published_by = $3 + WHERE id = $1 AND template_id = $2`, rule.VersionID, rule.TemplateID, nullableActor(actorID)); err != nil { + return err + } + } + } + raw, err := json.Marshal(policy) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_rule_policies SET policy = $1::jsonb WHERE id = 1`, string(raw)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_runtime + SET enabled = $1, expose_server_prompt = $2, compact_enabled = $3, + revision = revision + 1, updated_by = $4, updated_at = NOW() WHERE id = 1`, + input.Enabled, input.ExposeServerPrompt, input.CompactEnabled, nullableActor(actorID)); err != nil { + return err + } + return tx.Commit() +} + +type promptConfigStoredVersion struct { + managedSource string + body string + sha256 string + byteLength int + compositionMode string + bundleID string + bundleManifestSHA256 string +} + +func loadPromptConfigVersion(ctx context.Context, tx *sql.Tx, templateID, versionID int64) (promptConfigStoredVersion, error) { + var version promptConfigStoredVersion + var managedSource, bundleID, bundleManifestSHA256 sql.NullString + err := tx.QueryRowContext(ctx, `SELECT t.managed_source, v.body, v.sha256, v.byte_length, + v.composition_mode, v.bundle_id, v.bundle_manifest_sha256 + FROM system_prompt_templates t JOIN system_prompt_template_versions v ON v.template_id = t.id + WHERE t.id = $1 AND v.id = $2 AND t.deleted_at IS NULL FOR UPDATE OF t`, templateID, versionID).Scan( + &managedSource, &version.body, &version.sha256, &version.byteLength, + &version.compositionMode, &bundleID, &bundleManifestSHA256) + if errors.Is(err, sql.ErrNoRows) { + return version, service.ErrBusinessSystemPromptVersionNotFound + } + version.managedSource = nullableStringValue(managedSource) + version.bundleID = nullableStringValue(bundleID) + version.bundleManifestSHA256 = nullableStringValue(bundleManifestSHA256) + return version, err +} + +func checkPromptConfigAccountReferences(ctx context.Context, tx *sql.Tx, policy nativeapi.PromptRulePolicy) error { + ids := make([]string, 0, len(policy.Rules)) + for _, rule := range policy.Rules { + ids = append(ids, rule.ID) + } + idsJSON, err := json.Marshal(ids) + if err != nil { + return err + } + var dangling bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM accounts a, jsonb_array_elements_text( + CASE WHEN jsonb_typeof(a.extra->'prompt_skills'->'rule_ids') = 'array' + THEN a.extra->'prompt_skills'->'rule_ids' ELSE '[]'::jsonb END) ref + WHERE a.deleted_at IS NULL AND a.extra->'prompt_skills'->>'mode' = 'custom' + AND NOT ($1::jsonb ? ref.value))`, string(idsJSON)).Scan(&dangling); err != nil { + return err + } + if dangling { + return service.ErrPromptRuleReferenced + } + return nil +} diff --git a/backend/internal/repository/business_system_prompt_config_test.go b/backend/internal/repository/business_system_prompt_config_test.go new file mode 100644 index 000000000000..dc347121442c --- /dev/null +++ b/backend/internal/repository/business_system_prompt_config_test.go @@ -0,0 +1,182 @@ +package repository + +import ( + "context" + "database/sql/driver" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func promptConfigFixtureRule(id string, templateID, versionID int64) nativeapi.PromptRule { + return nativeapi.PromptRule{ID: id, Name: id, Enabled: true, TemplateID: templateID, VersionID: versionID, + Role: "auto", Platforms: []string{"openai"}, Position: "control_append", ModelMatch: "upstream", Models: []string{}} +} + +func promptConfigFixturePolicy(rules ...nativeapi.PromptRule) nativeapi.PromptRulePolicy { + ids := make([]string, 0, len(rules)) + for _, rule := range rules { + ids = append(ids, rule.ID) + } + return nativeapi.PromptRulePolicy{Version: 2, Rules: rules, DefaultRuleIDs: ids} +} + +func promptConfigExpectPrevious(t *testing.T, mock sqlmock.Sqlmock, policy nativeapi.PromptRulePolicy) { + t.Helper() + raw, err := json.Marshal(policy) + require.NoError(t, err) + mock.ExpectQuery("SELECT policy FROM system_prompt_rule_policies").WillReturnRows(sqlmock.NewRows([]string{"policy"}).AddRow(raw)) +} + +func promptConfigExpectVersion(t *testing.T, mock sqlmock.Sqlmock, managed any, body string, templateID, versionID int64) { + t.Helper() + hash, size, err := service.ValidateBusinessSystemPromptBody(body) + require.NoError(t, err) + mock.ExpectQuery("SELECT t.managed_source, v.body").WithArgs(templateID, versionID).WillReturnRows( + sqlmock.NewRows([]string{"managed_source", "body", "sha256", "byte_length", "composition_mode", "bundle_id", "bundle_manifest_sha256"}). + AddRow(managed, body, hash, size, "inline", nil, nil)) +} + +type promptConfigPolicyMatcher struct { + versionByRule map[string]int64 + newTemplateID int64 +} + +func (m promptConfigPolicyMatcher) Match(value driver.Value) bool { + raw, ok := value.(string) + if !ok { + return false + } + var policy nativeapi.PromptRulePolicy + if json.Unmarshal([]byte(raw), &policy) != nil || policy.Version != 2 || len(policy.Rules) != len(m.versionByRule) { + return false + } + for _, rule := range policy.Rules { + if rule.VersionID != m.versionByRule[rule.ID] || rule.FollowActive || rule.Delivery != "" { + return false + } + if rule.ID == "new" && rule.TemplateID != m.newTemplateID { + return false + } + } + return len(policy.DefaultRuleIDs) == 1 && policy.DefaultRuleIDs[0] == "new" +} + +func TestPromptConfigAtomicSavePreservesOtherPinnedRules(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + first := promptConfigFixtureRule("edited", 2, 5) + second := promptConfigFixtureRule("shared", 2, 5) + third := promptConfigFixtureRule("new", 0, 0) + previous := promptConfigFixturePolicy(first, second) + input := service.PromptConfigUpdate{ExpectedRevision: 7, Enabled: true, ExposeServerPrompt: true, + Policy: promptConfigFixturePolicy(first, second, third), + Contents: map[string]service.PromptContentDraft{"edited": {Body: "edited content"}, "new": {Body: "new content"}}} + input.Policy.DefaultRuleIDs = []string{"new"} + mock.ExpectBegin() + mock.ExpectQuery("SELECT revision FROM system_prompt_runtime").WillReturnRows(sqlmock.NewRows([]string{"revision"}).AddRow(7)) + promptConfigExpectPrevious(t, mock, previous) + mock.ExpectQuery("SELECT EXISTS").WithArgs(`["edited","shared","new"]`).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + promptConfigExpectVersion(t, mock, nil, "original content", 2, 5) + mock.ExpectQuery("SELECT COALESCE\\(MAX\\(version\\), 0\\)").WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows([]string{"max"}).AddRow(4)) + hash, size, err := service.ValidateBusinessSystemPromptBody("edited content") + require.NoError(t, err) + mock.ExpectQuery("INSERT INTO system_prompt_template_versions").WithArgs(int64(2), int64(5), "edited content", hash, size, "inline", nil, nil, int64(9)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(99)) + promptConfigExpectVersion(t, mock, nil, "original content", 2, 5) + mock.ExpectExec("UPDATE system_prompt_template_versions").WithArgs(int64(5), int64(2), int64(9)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("INSERT INTO system_prompt_templates").WithArgs(sqlmock.AnyArg(), "new", int64(9)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(3)) + mock.ExpectQuery("SELECT COALESCE\\(MAX\\(version\\), 0\\)").WithArgs(int64(3)).WillReturnRows(sqlmock.NewRows([]string{"max"}).AddRow(0)) + hash, size, err = service.ValidateBusinessSystemPromptBody("new content") + require.NoError(t, err) + mock.ExpectQuery("INSERT INTO system_prompt_template_versions").WithArgs(int64(3), int64(1), "new content", hash, size, "inline", nil, nil, int64(9)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(100)) + mock.ExpectExec("UPDATE system_prompt_rule_policies").WithArgs(promptConfigPolicyMatcher{map[string]int64{"edited": 99, "shared": 5, "new": 100}, 3}).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE system_prompt_runtime").WithArgs(true, true, false, int64(9)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + repo := &businessSystemPromptRepository{db: db} + require.NoError(t, repo.SavePromptConfig(context.Background(), input, 9)) + require.Equal(t, int64(5), input.Policy.Rules[0].VersionID, "saving must not mutate the editor's previous snapshot") + require.Zero(t, input.Policy.Rules[2].TemplateID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPromptConfigRejectsConflictDanglingManagedAndSourceDrift(t *testing.T) { + for _, scenario := range []string{"revision", "referenced", "managed", "source"} { + t.Run(scenario, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + previous := promptConfigFixturePolicy(promptConfigFixtureRule("one", 2, 5)) + input := service.PromptConfigUpdate{ExpectedRevision: 7, Enabled: true, Policy: promptConfigFixturePolicy(promptConfigFixtureRule("one", 2, 5)), + Contents: map[string]service.PromptContentDraft{"one": {Body: "edited content"}}} + if scenario == "source" { + input.Policy.Rules[0].TemplateID = 3 + } + mock.ExpectBegin() + revision := int64(7) + if scenario == "revision" { + revision++ + } + mock.ExpectQuery("SELECT revision FROM system_prompt_runtime").WillReturnRows(sqlmock.NewRows([]string{"revision"}).AddRow(revision)) + if scenario != "revision" { + promptConfigExpectPrevious(t, mock, previous) + mock.ExpectQuery("SELECT EXISTS").WithArgs(`["one"]`).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(scenario == "referenced")) + if scenario == "managed" { + promptConfigExpectVersion(t, mock, "github-seed", "managed source body", 2, 5) + } + } + mock.ExpectRollback() + err = (&businessSystemPromptRepository{db: db}).SavePromptConfig(context.Background(), input, 9) + switch scenario { + case "revision": + require.ErrorIs(t, err, service.ErrBusinessSystemPromptRevisionConflict) + case "referenced": + require.ErrorIs(t, err, service.ErrPromptRuleReferenced) + case "managed": + require.ErrorIs(t, err, service.ErrBusinessSystemPromptSourceNotManaged) + case "source": + require.ErrorIs(t, err, service.ErrBusinessSystemPromptInvalid) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestPromptConfigV2SnapshotDoesNotReadGlobalContent(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + policy := promptConfigFixturePolicy(promptConfigFixtureRule("one", 2, 5)) + raw, err := json.Marshal(policy) + require.NoError(t, err) + mock.ExpectBegin() + mock.ExpectQuery("SELECT r.enabled, r.expose_server_prompt, r.compact_enabled").WillReturnRows( + sqlmock.NewRows([]string{"enabled", "expose_server_prompt", "compact_enabled", "revision", "updated_at", "policy"}).AddRow(true, false, true, 7, time.Now(), raw)) + mock.ExpectCommit() + snapshot, err := (&businessSystemPromptRepository{db: db}).LoadBusinessSystemPromptRules(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(7), snapshot.Revision) + require.Equal(t, policy, *snapshot.RulePolicy) + require.Empty(t, snapshot.Body) + require.Zero(t, snapshot.TemplateID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPromptConfigContentLimitRejectsBeforeTransaction(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + input := service.PromptConfigUpdate{ExpectedRevision: 7, Policy: promptConfigFixturePolicy(promptConfigFixtureRule("new", 0, 0)), + Contents: map[string]service.PromptContentDraft{"new": {Body: strings.Repeat("x", 65537)}}} + err = (&businessSystemPromptRepository{db: db}).SavePromptConfig(context.Background(), input, 9) + require.Error(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/business_system_prompt_repository.go b/backend/internal/repository/business_system_prompt_repository.go index 97f91ec672b0..56a47f373a3b 100644 --- a/backend/internal/repository/business_system_prompt_repository.go +++ b/backend/internal/repository/business_system_prompt_repository.go @@ -101,7 +101,8 @@ func (r *businessSystemPromptRepository) EnsureBusinessSystemPromptSeed(ctx cont if _, err := tx.ExecContext(ctx, ` UPDATE system_prompt_runtime SET active_template_id = $1, active_version_id = $2, updated_at = NOW() - WHERE id = 1 AND active_template_id IS NULL`, templateID, versionID); err != nil { + WHERE id = 1 AND active_template_id IS NULL + AND NOT EXISTS (SELECT 1 FROM system_prompt_rule_policies WHERE id = 1 AND policy->>'version' = '2')`, templateID, versionID); err != nil { return fmt.Errorf("activate initial system prompt seed: %w", err) } if err := tx.Commit(); err != nil { @@ -186,7 +187,8 @@ func ensureExistingBusinessSystemPromptSeed( if _, err := tx.ExecContext(ctx, ` UPDATE system_prompt_runtime SET active_version_id = $1, revision = revision + 1, updated_at = NOW() - WHERE id = 1 AND active_template_id = $2 AND active_version_id = $3`, + WHERE id = 1 AND active_template_id = $2 AND active_version_id = $3 + AND NOT EXISTS (SELECT 1 FROM system_prompt_rule_policies WHERE id = 1 AND policy->>'version' = '2')`, versionID, templateID, activeVersionID.Int64); err != nil { return err } @@ -222,7 +224,8 @@ func activateExistingBusinessSystemPromptSeedCandidate( _, err := tx.ExecContext(ctx, ` UPDATE system_prompt_runtime SET active_version_id = $1, revision = revision + 1, updated_at = NOW() - WHERE id = 1 AND active_template_id = $2 AND active_version_id = $3`, + WHERE id = 1 AND active_template_id = $2 AND active_version_id = $3 + AND NOT EXISTS (SELECT 1 FROM system_prompt_rule_policies WHERE id = 1 AND policy->>'version' = '2')`, candidateVersionID, templateID, activeVersionID.Int64) return err } @@ -332,10 +335,19 @@ func (r *businessSystemPromptRepository) GetBusinessSystemPromptTemplate(ctx con if err != nil { return service.BusinessSystemPromptTemplateDetail{}, err } - runtime, err := r.LoadBusinessSystemPrompt(ctx) + runtime, err := r.LoadBusinessSystemPromptRules(ctx) if err == nil { for i := range versions { - versions[i].IsActive = versions[i].ID == runtime.VersionID + if runtime.RulePolicy == nil { + versions[i].IsActive = versions[i].ID == runtime.VersionID + continue + } + for _, rule := range runtime.RulePolicy.Rules { + if rule.TemplateID == id && rule.VersionID == versions[i].ID { + versions[i].IsActive = true + break + } + } } } return service.BusinessSystemPromptTemplateDetail{Template: template, Versions: versions}, nil @@ -675,7 +687,9 @@ func (r *businessSystemPromptRepository) SoftDeleteBusinessSystemPromptTemplate( return service.ErrBusinessSystemPromptSeedProtected } var activeID sql.NullInt64 - if err := tx.QueryRowContext(ctx, `SELECT active_template_id FROM system_prompt_runtime WHERE id = 1`).Scan(&activeID); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT CASE WHEN EXISTS ( + SELECT 1 FROM system_prompt_rule_policies WHERE id = 1 AND policy->>'version' = '2' + ) THEN NULL ELSE active_template_id END FROM system_prompt_runtime WHERE id = 1`).Scan(&activeID); err != nil { return err } if activeID.Valid && activeID.Int64 == id { @@ -744,6 +758,13 @@ func (r *businessSystemPromptRepository) PublishBusinessSystemPromptVersion(ctx if err := validateStoredBusinessSystemPromptVersion(body, hash, byteLength, compositionMode, nullableStringValue(bundleID), nullableStringValue(bundleManifestSHA256)); err != nil { return service.BusinessSystemPromptSnapshot{}, err } + var unified bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM system_prompt_rule_policies WHERE id = 1 AND policy->>'version' = '2')`).Scan(&unified); err != nil { + return service.BusinessSystemPromptSnapshot{}, err + } + if unified { + return service.BusinessSystemPromptSnapshot{}, fmt.Errorf("%w: publishing requires explicit target rules", service.ErrBusinessSystemPromptInvalid) + } if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_template_versions SET published_at = COALESCE(published_at, NOW()), published_by = $3 WHERE id = $1 AND template_id = $2`, versionID, templateID, nullableActor(actorID)); err != nil { return service.BusinessSystemPromptSnapshot{}, err } @@ -771,6 +792,20 @@ func (r *businessSystemPromptRepository) PublishBusinessSystemPromptVersion(ctx } func (r *businessSystemPromptRepository) UpdateBusinessSystemPromptRuntime(ctx context.Context, update service.BusinessSystemPromptRuntimeUpdate) (service.BusinessSystemPromptSnapshot, error) { + current, err := r.LoadBusinessSystemPromptRules(ctx) + if err != nil { + return service.BusinessSystemPromptSnapshot{}, err + } + if current.RulePolicy != nil && current.RulePolicy.Version == 2 { + if err := r.SavePromptConfig(ctx, service.PromptConfigUpdate{ + ExpectedRevision: update.ExpectedRevision, Enabled: update.Enabled, + ExposeServerPrompt: update.ExposeServerPrompt, CompactEnabled: update.CompactEnabled, + Policy: *current.RulePolicy, + }, update.ActorID); err != nil { + return service.BusinessSystemPromptSnapshot{}, err + } + return r.LoadBusinessSystemPromptRules(ctx) + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return service.BusinessSystemPromptSnapshot{}, err diff --git a/backend/internal/repository/business_system_prompt_rules.go b/backend/internal/repository/business_system_prompt_rules.go index 25fe65a8f6c2..ccea8f1a6585 100644 --- a/backend/internal/repository/business_system_prompt_rules.go +++ b/backend/internal/repository/business_system_prompt_rules.go @@ -16,13 +16,18 @@ func (r *businessSystemPromptRepository) LoadBusinessSystemPromptRules(ctx conte return service.BusinessSystemPromptSnapshot{}, err } defer func() { _ = tx.Rollback() }() - snapshot, err := loadBusinessSystemPromptSnapshot(ctx, tx) - if err != nil { - return snapshot, err - } + var snapshot service.BusinessSystemPromptSnapshot var raw []byte - err = tx.QueryRowContext(ctx, `SELECT policy FROM system_prompt_rule_policies WHERE id = 1`).Scan(&raw) + err = tx.QueryRowContext(ctx, `SELECT r.enabled, r.expose_server_prompt, r.compact_enabled, + r.revision, r.updated_at, p.policy + FROM system_prompt_runtime r JOIN system_prompt_rule_policies p ON p.id = r.id + WHERE r.id = 1`).Scan(&snapshot.Enabled, &snapshot.ExposeServerPrompt, &snapshot.CompactEnabled, + &snapshot.Revision, &snapshot.UpdatedAt, &raw) if errors.Is(err, sql.ErrNoRows) { + snapshot, err = loadBusinessSystemPromptSnapshot(ctx, tx) + if err != nil { + return snapshot, err + } return snapshot, tx.Commit() } if err != nil { @@ -32,66 +37,29 @@ func (r *businessSystemPromptRepository) LoadBusinessSystemPromptRules(ctx conte if err := json.Unmarshal(raw, &policy); err != nil { return snapshot, err } + if policy.Version < extensionv1.PromptRulePolicyVersion { + // Only the legacy import path reads the former global selection. V2 + // content is resolved exclusively from each rule's immutable version. + snapshot, err = loadBusinessSystemPromptSnapshot(ctx, tx) + if err != nil { + return snapshot, err + } + } snapshot.RulePolicy = &policy return snapshot, tx.Commit() } func (r *businessSystemPromptRepository) UpdateBusinessSystemPromptRules(ctx context.Context, policy extensionv1.PromptRulePolicy, expectedRevision, actorID int64) error { - raw, err := json.Marshal(policy) - if err != nil { - return err + if policy.Version != extensionv1.PromptRulePolicyVersion { + return service.ErrBusinessSystemPromptInvalid } - tx, err := r.db.BeginTx(ctx, nil) + current, err := r.LoadBusinessSystemPromptRules(ctx) if err != nil { return 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 err - } - if revision != expectedRevision { - return service.ErrBusinessSystemPromptRevisionConflict - } - for _, rule := range policy.Rules { - if rule.FollowActive { - continue - } - var exists bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM system_prompt_template_versions v - JOIN system_prompt_templates t ON t.id = v.template_id - WHERE v.id = $1 AND t.id = $2 AND t.deleted_at IS NULL)`, rule.VersionID, rule.TemplateID).Scan(&exists); err != nil { - return err - } - if !exists { - return service.ErrBusinessSystemPromptVersionNotFound - } - } - // A rule cannot disappear while an account references it. Off/disabled rules - // remain editable and visibly disabled without silently changing account intent. - ids := make([]string, 0, len(policy.Rules)) - for _, rule := range policy.Rules { - ids = append(ids, rule.ID) - } - idsJSON, _ := json.Marshal(ids) - var dangling bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( - SELECT 1 FROM accounts a, jsonb_array_elements_text( - CASE WHEN jsonb_typeof(a.extra->'prompt_skills'->'rule_ids') = 'array' - THEN a.extra->'prompt_skills'->'rule_ids' ELSE '[]'::jsonb END) ref - WHERE a.deleted_at IS NULL AND a.extra->'prompt_skills'->>'mode' = 'custom' - AND NOT ($1::jsonb ? ref.value))`, string(idsJSON)).Scan(&dangling); err != nil { - return err - } - if dangling { - return service.ErrPromptRuleReferenced - } - if _, err := tx.ExecContext(ctx, `INSERT INTO system_prompt_rule_policies (id, policy) VALUES (1, $1::jsonb) - ON CONFLICT (id) DO UPDATE SET policy = EXCLUDED.policy`, string(raw)); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_runtime SET revision = revision + 1, updated_by = $1, updated_at = NOW() WHERE id = 1`, actorID); err != nil { - return err - } - return tx.Commit() + return r.SavePromptConfig(ctx, service.PromptConfigUpdate{ + ExpectedRevision: expectedRevision, Enabled: current.Enabled, + ExposeServerPrompt: current.ExposeServerPrompt, CompactEnabled: current.CompactEnabled, + Policy: policy, + }, actorID) } 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 9cdad31fadaf..46eb322859e1 100644 --- a/backend/internal/repository/business_system_prompt_rules_integration_test.go +++ b/backend/internal/repository/business_system_prompt_rules_integration_test.go @@ -15,6 +15,7 @@ import ( entsql "entgo.io/ent/dialect/sql" dbent "github.com/Wei-Shaw/sub2api/ent" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/migrations" "github.com/jackc/pgx/v5" @@ -85,7 +86,7 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { // Copy current production columns/checks/defaults/indexes. LIKE deliberately // excludes FKs; reattach their real definitions to the private prompt tables. - tables := []string{"system_prompt_templates", "system_prompt_template_versions", "system_prompt_runtime"} + tables := []string{"system_prompt_templates", "system_prompt_template_versions", "system_prompt_runtime", "settings"} for _, table := range tables { quoted := pgx.Identifier{table}.Sanitize() _, err := db.ExecContext(ctx, "CREATE TABLE "+quotedSchema+"."+quoted+" (LIKE public."+quoted+" INCLUDING ALL)") @@ -113,6 +114,10 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { require.NoError(t, err) } } + _, err = db.ExecContext(ctx, `CREATE TRIGGER prompt_fixture_immutable + BEFORE UPDATE ON system_prompt_template_versions + FOR EACH ROW EXECUTE FUNCTION public.protect_system_prompt_version_content()`) + require.NoError(t, err) actor := createEntUser(t, ctx, testEntClient(t), "prompt-rules-"+suffix+"@example.invalid") actorID = actor.ID @@ -157,22 +162,72 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { require.Equal(t, versionID, loaded.RulePolicy.Rules[0].VersionID) require.True(t, loaded.RulePolicy.Rules[0].FollowActive) + // Each old domain keeps its own enabled state. The structured fixture also + // exercises a literal placeholder inside expansion text and cache metadata. + blocks := `[{"text":"before"},{"text":"{billing_header}"},{"text":"{claude_code_system_prompt}"},{"enabled":false,"text":"disabled"},{"text":"{claude_code_expansion_prompt}","cache_control":true},{"text":"after","cache_control":{"type":"ephemeral","ttl":"1h","fixture":"retained"}}]` + _, err = db.ExecContext(ctx, `INSERT INTO settings (key,value,updated_at) VALUES + ('enable_claude_oauth_system_prompt_injection','true',NOW()), + ('claude_oauth_system_prompt',$1,NOW()), ('claude_oauth_system_prompt_blocks',$2,NOW())`, "literal {fp} custom expansion", blocks) + require.NoError(t, err) + _, err = db.ExecContext(ctx, `UPDATE system_prompt_runtime SET enabled=false WHERE id=1`) + require.NoError(t, err) + v2Migration, err := migrations.FS.ReadFile("255_prompt_rule_policy_v2.sql") + require.NoError(t, err) + _, err = db.ExecContext(ctx, string(v2Migration)) + require.NoError(t, err) + loaded, err = prompts.LoadBusinessSystemPromptRules(ctx) + require.NoError(t, err) + require.Equal(t, int64(42), loaded.Revision) + require.True(t, loaded.Enabled && loaded.ExposeServerPrompt && loaded.CompactEnabled) + require.Zero(t, loaded.TemplateID, "v2 snapshot must not use the former global selection") + require.Equal(t, 2, loaded.RulePolicy.Version) + require.Len(t, loaded.RulePolicy.Rules, 2) + legacy, claudeRule := loaded.RulePolicy.Rules[0], loaded.RulePolicy.Rules[1] + require.False(t, legacy.Enabled, "old OpenAI domain was disabled") + require.False(t, legacy.FollowActive) + require.Empty(t, legacy.Delivery) + require.Equal(t, "auto", legacy.Role) + require.Equal(t, []string{"openai", "cindy"}, legacy.Platforms) + require.Equal(t, detail.Template.ID, legacy.TemplateID) + require.Equal(t, versionID, legacy.VersionID) + require.True(t, claudeRule.Enabled, "the independently enabled Claude domain must remain enabled") + require.Equal(t, []string{"anthropic"}, claudeRule.Platforms) + require.Equal(t, []string{"oauth", "setup-token"}, claudeRule.AccountTypes) + require.Equal(t, []string{"generic-mimic"}, claudeRule.RequestProfiles) + require.Equal(t, []string{"fable"}, claudeRule.ExcludeModelContains) + var structured, archivedPolicy, archivedRuntime string + require.NoError(t, db.QueryRowContext(ctx, `SELECT body FROM system_prompt_template_versions WHERE id=$1`, claudeRule.VersionID).Scan(&structured)) + var envelope struct { + Blocks []map[string]any `json:"blocks"` + Expansion string `json:"expansion_prompt"` + } + require.NoError(t, json.Unmarshal([]byte(structured), &envelope)) + require.Len(t, envelope.Blocks, 6) + require.Equal(t, "before", envelope.Blocks[0]["text"]) + require.Equal(t, false, envelope.Blocks[3]["enabled"]) + require.Equal(t, "literal {fp} custom expansion", envelope.Expansion) + require.Equal(t, map[string]any{"type": "ephemeral", "ttl": "5m"}, envelope.Blocks[4]["cache_control"]) + require.Equal(t, map[string]any{"type": "ephemeral", "ttl": "1h", "fixture": "retained"}, envelope.Blocks[5]["cache_control"]) + require.NoError(t, db.QueryRowContext(ctx, `SELECT legacy_policy::text,legacy_runtime::text FROM system_prompt_rule_policies WHERE id=1`).Scan(&archivedPolicy, &archivedRuntime)) + require.Contains(t, archivedPolicy, `"follow_active": true`) + require.Contains(t, archivedRuntime, `"enabled": false`) + policy := *loaded.RulePolicy policy.Rules[0].Name = "Configured fixture rule" policy.Rules[0].Order = 17 - policy.Rules[0].FollowActive = false - require.NoError(t, prompts.UpdateBusinessSystemPromptRules(ctx, policy, 41, actorID)) + policy.Rules[0].Enabled = true + require.NoError(t, prompts.UpdateBusinessSystemPromptRules(ctx, policy, 42, actorID)) loaded, err = prompts.LoadBusinessSystemPromptRules(ctx) require.NoError(t, err) - require.Equal(t, int64(42), loaded.Revision) + require.Equal(t, int64(43), loaded.Revision) require.Equal(t, "Configured fixture rule", loaded.RulePolicy.Rules[0].Name) require.True(t, loaded.Enabled && loaded.ExposeServerPrompt && loaded.CompactEnabled) configuredPolicy, configuredRuntime := readPolicy(), readRuntime() - _, err = db.ExecContext(ctx, string(migration)) + _, err = db.ExecContext(ctx, string(v2Migration)) require.NoError(t, err) require.JSONEq(t, configuredPolicy, readPolicy(), "replaying migration must not replace custom rules") require.JSONEq(t, configuredRuntime, readRuntime()) - conflicting := extensionv1.PromptRulePolicy{Version: 1, Rules: []extensionv1.PromptRule{}, DefaultRuleIDs: []string{}} + conflicting := extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{}, DefaultRuleIDs: []string{}} require.ErrorIs(t, prompts.UpdateBusinessSystemPromptRules(ctx, conflicting, 41, actorID), service.ErrBusinessSystemPromptRevisionConflict) require.JSONEq(t, configuredPolicy, readPolicy()) require.JSONEq(t, configuredRuntime, readRuntime()) @@ -193,7 +248,7 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { var originalExtra, originalCredentials string require.NoError(t, db.QueryRowContext(ctx, `SELECT extra::text, credentials::text FROM accounts WHERE id=$1`, accountID).Scan(&originalExtra, &originalCredentials)) binding := extensionv1.PromptAccountBinding{Mode: "custom", RuleIDs: []string{"legacy-default"}} - applied, err := accounts.UpdatePromptBindingIfRevision(ctx, accountID, stale.UpdatedAt, 42, binding) + applied, err := accounts.UpdatePromptBindingIfRevision(ctx, accountID, stale.UpdatedAt, 43, binding) require.NoError(t, err) require.True(t, applied) assertBinding := func() { @@ -207,7 +262,7 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { require.JSONEq(t, originalCredentials, credentials) } assertBinding() - applied, err = accounts.UpdatePromptBindingIfRevision(ctx, accountID, stale.UpdatedAt, 42, extensionv1.PromptAccountBinding{Mode: "off"}) + applied, err = accounts.UpdatePromptBindingIfRevision(ctx, accountID, stale.UpdatedAt, 43, extensionv1.PromptAccountBinding{Mode: "off"}) require.NoError(t, err) require.False(t, applied, "stale account revision cannot overwrite the binding") assertBinding() @@ -217,7 +272,7 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { require.ErrorIs(t, err, service.ErrBusinessSystemPromptRevisionConflict) require.False(t, applied) assertBinding() - require.ErrorIs(t, prompts.UpdateBusinessSystemPromptRules(ctx, conflicting, 42, actorID), service.ErrPromptRuleReferenced) + require.ErrorIs(t, prompts.UpdateBusinessSystemPromptRules(ctx, conflicting, 43, actorID), service.ErrPromptRuleReferenced) require.JSONEq(t, configuredPolicy, readPolicy()) require.JSONEq(t, configuredRuntime, readRuntime()) @@ -228,7 +283,143 @@ func TestPromptRulesMigrationAndAccountBindingIntegration(t *testing.T) { current, err = accounts.GetByID(ctx, accountID) require.NoError(t, err) require.Equal(t, stale.Name, current.Name) + + var versionCount int + 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)) + edit := service.PromptConfigUpdate{ + ExpectedRevision: 43, Enabled: true, ExposeServerPrompt: true, CompactEnabled: true, + Policy: badPolicy, Contents: map[string]service.PromptContentDraft{"legacy-default": {Body: "Atomic replacement content."}}, + } + require.ErrorIs(t, prompts.SavePromptConfig(ctx, edit, actorID), service.ErrBusinessSystemPromptVersionNotFound) + require.JSONEq(t, configuredPolicy, readPolicy()) + require.JSONEq(t, configuredRuntime, readRuntime()) + var countAfterFailure int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM system_prompt_template_versions`).Scan(&countAfterFailure)) + require.Equal(t, versionCount, countAfterFailure, "a later bad reference rolls back an already inserted content version") + edit.Policy = policy + require.NoError(t, prompts.SavePromptConfig(ctx, edit, actorID)) + loaded, err = prompts.LoadBusinessSystemPromptRules(ctx) + require.NoError(t, err) + require.Equal(t, int64(44), loaded.Revision, "content and rules share one revision increment") + require.NotEqual(t, versionID, loaded.RulePolicy.Rules[0].VersionID) + require.Equal(t, claudeRule.VersionID, loaded.RulePolicy.Rules[1].VersionID) + var originalBody string + require.NoError(t, db.QueryRowContext(ctx, `SELECT body FROM system_prompt_template_versions WHERE id=$1`, versionID).Scan(&originalBody)) + require.Equal(t, detail.Versions[0].Body, originalBody) + _, err = db.ExecContext(ctx, `UPDATE system_prompt_template_versions SET body='must fail' WHERE id=$1`, versionID) + require.Error(t, err, "immutable version trigger remains active") + assertBinding() + + // Exercise the fresh-install placeholder in the same fixture. Every change, + // including this tiny account shadow table, is rolled back afterwards. + freshTx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer func() { _ = freshTx.Rollback() }() + _, err = freshTx.ExecContext(ctx, `CREATE TABLE accounts (id BIGINT PRIMARY KEY, deleted_at TIMESTAMPTZ, extra JSONB); + ALTER TABLE system_prompt_rule_policies DROP CONSTRAINT system_prompt_rule_policy_v2; + DELETE FROM settings; + UPDATE system_prompt_runtime SET active_template_id=NULL,active_version_id=NULL,enabled=false WHERE id=1; + UPDATE system_prompt_rule_policies SET policy='{"version":1,"default_rule_ids":["legacy-default"],"rules":[{"id":"legacy-default","name":"Default","enabled":true,"template_id":0,"version_id":0,"follow_active":true,"delivery":"native_control","position":"control_append","models":[]}]}'::jsonb WHERE id=1; + INSERT INTO accounts VALUES (1,NULL,'{"prompt_skills":{"mode":"custom","rule_ids":["legacy-default"]}}'); + SAVEPOINT referenced_empty_seed`) + require.NoError(t, err) + _, err = freshTx.ExecContext(ctx, string(v2Migration)) + require.Error(t, err, "an empty legacy rule with an account reference must not disappear") + _, err = freshTx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT referenced_empty_seed; DELETE FROM accounts`) + require.NoError(t, err) + _, err = freshTx.ExecContext(ctx, string(v2Migration)) + require.NoError(t, err) + var freshRules, freshDefaults int + var freshRevision int64 + require.NoError(t, freshTx.QueryRowContext(ctx, `SELECT jsonb_array_length(p.policy->'rules'),jsonb_array_length(p.policy->'default_rule_ids'),r.revision + FROM system_prompt_rule_policies p JOIN system_prompt_runtime r ON r.id=p.id WHERE p.id=1`).Scan(&freshRules, &freshDefaults, &freshRevision)) + require.Zero(t, freshRules, "empty default Claude settings must not create a custom rule") + require.Zero(t, freshDefaults) + require.Equal(t, int64(45), freshRevision) + _, err = freshTx.ExecContext(ctx, string(v2Migration)) + require.NoError(t, err) + require.NoError(t, freshTx.QueryRowContext(ctx, `SELECT revision FROM system_prompt_runtime WHERE id=1`).Scan(&freshRevision)) + require.Equal(t, int64(45), freshRevision, "replaying the v2 migration does not publish another revision") + require.NoError(t, freshTx.Rollback()) + + t.Run("full_legacy_policy_and_independent_claude_domain", func(t *testing.T) { + // Both domains were independently valid before v2. Merging them must + // preserve all 32 rule IDs and the additional Claude source. Keep the + // migration and its DDL inside a transaction that is always rolled back. + legacyCapacity := extensionv1.PromptRulePolicy{Version: 1} + for i := 0; i < 32; i++ { + id := fmt.Sprintf("legacy-capacity-%02d", i) + if i == 0 { + id = "legacy-default" // Preserve the fixture account's existing reference. + } + rule := extensionv1.PromptRule{ + ID: id, Name: fmt.Sprintf("Legacy capacity rule %02d", i), Enabled: i%3 != 0, + TemplateID: detail.Template.ID, VersionID: versionID, FollowActive: i%2 == 0, + Order: i, Delivery: "native_control", Position: "control_append", ModelMatch: "upstream", Models: []string{}, + } + if rule.FollowActive { + rule.TemplateID, rule.VersionID = 0, 0 + } + legacyCapacity.Rules = append(legacyCapacity.Rules, rule) + legacyCapacity.DefaultRuleIDs = append(legacyCapacity.DefaultRuleIDs, id) + } + legacyRaw, err := json.Marshal(legacyCapacity) + require.NoError(t, err) + capacityTx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer func() { _ = capacityTx.Rollback() }() + _, err = capacityTx.ExecContext(ctx, `ALTER TABLE system_prompt_rule_policies DROP CONSTRAINT system_prompt_rule_policy_v2; + UPDATE system_prompt_runtime SET enabled=true WHERE id=1`) + require.NoError(t, err) + _, err = capacityTx.ExecContext(ctx, `UPDATE system_prompt_rule_policies + SET policy=$1::jsonb,legacy_policy=NULL,legacy_runtime=NULL WHERE id=1`, string(legacyRaw)) + require.NoError(t, err) + _, err = capacityTx.ExecContext(ctx, string(v2Migration)) + require.NoError(t, err, "32 legacy rules and the separately configured Claude source must migrate together") + + // Read and validate the migrated v2 policy on this same transaction; + // a separate repository connection cannot observe uncommitted DDL/data. + var migratedRaw, preservedRaw []byte + var capacitySnapshot service.BusinessSystemPromptSnapshot + require.NoError(t, capacityTx.QueryRowContext(ctx, `SELECT r.enabled,r.expose_server_prompt,r.compact_enabled, + r.revision,r.updated_at,p.policy,p.legacy_policy + FROM system_prompt_runtime r JOIN system_prompt_rule_policies p ON p.id=r.id WHERE r.id=1`).Scan( + &capacitySnapshot.Enabled, &capacitySnapshot.ExposeServerPrompt, &capacitySnapshot.CompactEnabled, + &capacitySnapshot.Revision, &capacitySnapshot.UpdatedAt, &migratedRaw, &preservedRaw)) + var capacityPolicy extensionv1.PromptRulePolicy + require.NoError(t, json.Unmarshal(migratedRaw, &capacityPolicy)) + capacityPolicy, err = promptpolicy.ValidateRulePolicy(capacityPolicy) + require.NoError(t, err, "the migrated union must also pass the runtime's v2 policy validator") + require.Equal(t, extensionv1.PromptRulePolicyVersion, capacityPolicy.Version) + require.Len(t, capacityPolicy.Rules, 33) + require.True(t, capacitySnapshot.Enabled && capacitySnapshot.ExposeServerPrompt && capacitySnapshot.CompactEnabled) + require.Equal(t, int64(45), capacitySnapshot.Revision) + require.JSONEq(t, string(legacyRaw), string(preservedRaw)) + for i, before := range legacyCapacity.Rules { + after := capacityPolicy.Rules[i] + require.Equal(t, before.ID, after.ID) + require.Equal(t, before.Enabled, after.Enabled) + require.Equal(t, before.Order, after.Order) + require.Equal(t, []string{"openai", "cindy"}, after.Platforms) + require.Equal(t, detail.Template.ID, after.TemplateID) + require.Equal(t, versionID, after.VersionID) + require.False(t, after.FollowActive) + require.Empty(t, after.Delivery) + } + additionalClaude := capacityPolicy.Rules[32] + require.NotContains(t, legacyCapacity.DefaultRuleIDs, additionalClaude.ID) + require.True(t, additionalClaude.Enabled) + require.Equal(t, []string{"anthropic"}, additionalClaude.Platforms) + require.Equal(t, []string{"oauth", "setup-token"}, additionalClaude.AccountTypes) + require.Equal(t, []string{"generic-mimic"}, additionalClaude.RequestProfiles) + require.Equal(t, []string{"fable"}, additionalClaude.ExcludeModelContains) + require.Equal(t, append(append([]string{}, legacyCapacity.DefaultRuleIDs...), additionalClaude.ID), capacityPolicy.DefaultRuleIDs) + require.NoError(t, capacityTx.Rollback()) + }) var serverVersion string require.NoError(t, db.QueryRowContext(ctx, `SHOW server_version`).Scan(&serverVersion)) - t.Logf("PostgreSQL %s: migration retained runtime/active version, repeat retained policy, rule and account CAS held, referenced-rule delete rejected, stale account edit preserved binding", serverVersion) + t.Logf("PostgreSQL %s: v2 migration preserved independent domain scope and structured Claude blocks; atomic content rollback, immutable versions, one revision, account CAS and references held", serverVersion) } diff --git a/backend/internal/repository/business_system_prompt_rules_test.go b/backend/internal/repository/business_system_prompt_rules_test.go index 46d7ece6ca4b..5416029c37cf 100644 --- a/backend/internal/repository/business_system_prompt_rules_test.go +++ b/backend/internal/repository/business_system_prompt_rules_test.go @@ -2,52 +2,20 @@ package repository import ( "context" - "encoding/json" "testing" "github.com/DATA-DOG/go-sqlmock" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" ) -func TestPromptRulesRepositoryCASAndReferencedAccounts(t *testing.T) { - for _, scenario := range []string{"revision_conflict", "referenced", "save"} { - t.Run(scenario, func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer func() { _ = db.Close() }() - policy := extensionv1.PromptRulePolicy{Version: 1, Rules: []extensionv1.PromptRule{{ID: "default", Name: "Default", FollowActive: true, Delivery: "native_control", Position: "control_append"}}, DefaultRuleIDs: []string{"default"}} - mock.ExpectBegin() - revision := int64(7) - if scenario == "revision_conflict" { - revision++ - } - mock.ExpectQuery("SELECT revision FROM system_prompt_runtime").WillReturnRows(sqlmock.NewRows([]string{"revision"}).AddRow(revision)) - if scenario == "revision_conflict" { - mock.ExpectRollback() - } else { - mock.ExpectQuery("SELECT EXISTS").WithArgs(`["default"]`).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(scenario == "referenced")) - if scenario == "referenced" { - mock.ExpectRollback() - } else { - raw, _ := json.Marshal(policy) - mock.ExpectExec("INSERT INTO system_prompt_rule_policies").WithArgs(string(raw)).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("UPDATE system_prompt_runtime SET revision").WithArgs(int64(9)).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - } - } - repo := &businessSystemPromptRepository{db: db} - err = repo.UpdateBusinessSystemPromptRules(context.Background(), policy, 7, 9) - switch scenario { - case "revision_conflict": - require.ErrorIs(t, err, service.ErrBusinessSystemPromptRevisionConflict) - case "referenced": - require.ErrorIs(t, err, service.ErrPromptRuleReferenced) - default: - require.NoError(t, err) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } +func TestPromptRulesRepositoryRejectsLegacyPolicyWrites(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + repo := &businessSystemPromptRepository{db: db} + err = repo.UpdateBusinessSystemPromptRules(context.Background(), nativeapi.PromptRulePolicy{Version: 1}, 7, 9) + require.ErrorIs(t, err, service.ErrBusinessSystemPromptInvalid) + require.NoError(t, mock.ExpectationsWereMet(), "legacy requests must not write a second effective policy") } diff --git a/backend/internal/repository/remote_skill_prompt_targets_test.go b/backend/internal/repository/remote_skill_prompt_targets_test.go new file mode 100644 index 000000000000..d137fce15cfb --- /dev/null +++ b/backend/internal/repository/remote_skill_prompt_targets_test.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestPromptSourceTargetsRejectStaleOrPartialPublication(t *testing.T) { + for _, scenario := range []string{"stale", "missing_subscriber", "extra_target"} { + t.Run(scenario, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + mock.ExpectBegin() + mock.ExpectQuery("SELECT revision FROM system_prompt_runtime").WillReturnRows(sqlmock.NewRows([]string{"revision"}).AddRow(7)) + targets := service.PromptSourceTargets{ExpectedRevision: 7, RuleIDs: []string{"one"}} + wantErr := service.ErrBusinessSystemPromptInvalid + if scenario == "stale" { + targets.ExpectedRevision = 6 + wantErr = service.ErrBusinessSystemPromptRevisionConflict + } else { + rows := sqlmock.NewRows([]string{"id"}).AddRow("one") + if scenario == "missing_subscriber" { + rows.AddRow("two") + } else { + targets.RuleIDs = append(targets.RuleIDs, "extra") + } + mock.ExpectQuery("SELECT rule->>'id'").WillReturnRows(rows) + } + mock.ExpectRollback() + store := &remoteSkillRegistryRepository{db: db} + _, err = store.PublishRemoteSkillVersionWithPromptTargets(context.Background(), 3, 9, 2, targets) + require.ErrorIs(t, err, wantErr) + require.NoError(t, mock.ExpectationsWereMet(), "no source or prompt write is allowed after a target conflict") + }) + } +} diff --git a/backend/internal/repository/remote_skill_registry_repository.go b/backend/internal/repository/remote_skill_registry_repository.go index 4c6fa5b4692e..b54ec966dede 100644 --- a/backend/internal/repository/remote_skill_registry_repository.go +++ b/backend/internal/repository/remote_skill_registry_repository.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -290,6 +291,14 @@ func (r *remoteSkillRegistryRepository) ExpireRemoteSkillSyncJobs(ctx context.Co } func (r *remoteSkillRegistryRepository) PublishRemoteSkillVersion(ctx context.Context, versionID, expectedRevision, actorID int64) (service.RemoteSkillRegistrySnapshot, error) { + return r.publishRemoteSkillVersion(ctx, versionID, expectedRevision, actorID, nil) +} + +func (r *remoteSkillRegistryRepository) PublishRemoteSkillVersionWithPromptTargets(ctx context.Context, versionID, expectedRevision, actorID int64, targets service.PromptSourceTargets) (service.RemoteSkillRegistrySnapshot, error) { + return r.publishRemoteSkillVersion(ctx, versionID, expectedRevision, actorID, &targets) +} + +func (r *remoteSkillRegistryRepository) publishRemoteSkillVersion(ctx context.Context, versionID, expectedRevision, actorID int64, targets *service.PromptSourceTargets) (service.RemoteSkillRegistrySnapshot, error) { if err := r.requireDatabase(); err != nil { return service.RemoteSkillRegistrySnapshot{}, err } @@ -298,6 +307,46 @@ func (r *remoteSkillRegistryRepository) PublishRemoteSkillVersion(ctx context.Co return service.RemoteSkillRegistrySnapshot{}, err } defer func() { _ = tx.Rollback() }() + if targets != nil { + if err := lockBusinessSystemPromptRuntimeRevision(ctx, tx, targets.ExpectedRevision); err != nil { + return service.RemoteSkillRegistrySnapshot{}, err + } + ids := make(map[string]bool, len(targets.RuleIDs)) + for _, id := range targets.RuleIDs { + if id == "" || ids[id] || len(targets.RuleIDs) > nativeapi.PromptRulesMaxCount { + return service.RemoteSkillRegistrySnapshot{}, service.ErrBusinessSystemPromptInvalid + } + ids[id] = true + } + rows, err := tx.QueryContext(ctx, `SELECT rule->>'id' + FROM system_prompt_rule_policies p, jsonb_array_elements(p.policy->'rules') rule + JOIN system_prompt_template_versions v ON v.id = (rule->>'version_id')::bigint + WHERE p.id = 1 AND v.composition_mode = 'codex_skill_hybrid'`) + if err != nil { + return service.RemoteSkillRegistrySnapshot{}, err + } + matches := 0 + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return service.RemoteSkillRegistrySnapshot{}, err + } + if !ids[id] { + _ = rows.Close() + return service.RemoteSkillRegistrySnapshot{}, fmt.Errorf("%w: paired source affects all subscribed rules", service.ErrBusinessSystemPromptInvalid) + } + matches++ + } + rowErr := rows.Err() + closeErr := rows.Close() + if rowErr != nil || closeErr != nil { + return service.RemoteSkillRegistrySnapshot{}, errors.Join(rowErr, closeErr) + } + if matches != len(ids) { + return service.RemoteSkillRegistrySnapshot{}, service.ErrBusinessSystemPromptInvalid + } + } if err := lockRemoteSkillRevision(ctx, tx, expectedRevision); err != nil { return service.RemoteSkillRegistrySnapshot{}, err } @@ -324,6 +373,11 @@ func (r *remoteSkillRegistryRepository) PublishRemoteSkillVersion(ctx context.Co WHERE id = 1`, versionID, promptID, nullableActor(actorID)); err != nil { return service.RemoteSkillRegistrySnapshot{}, err } + if targets != nil { + if _, err := tx.ExecContext(ctx, `UPDATE system_prompt_runtime SET revision = revision + 1, updated_by = $1, updated_at = NOW() WHERE id = 1`, nullableActor(actorID)); err != nil { + return service.RemoteSkillRegistrySnapshot{}, err + } + } snapshot, err := loadRemoteSkillRegistrySnapshot(ctx, tx) if err != nil { return service.RemoteSkillRegistrySnapshot{}, err diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index da922d599169..a42b33ed0d60 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -59,7 +59,7 @@ var auditExtraAllowedKeys = map[string]struct{}{ "event_id": {}, "requested_count": {}, "deleted_events": {}, "deleted_jobs": {}, "matched_count": {}, "snapshot_max_id": {}, "filter_hash": {}, "confirm": {}, "template_id": {}, "template_version": {}, "old_sha256": {}, "new_sha256": {}, - "byte_length": {}, "revision": {}, "expose_server_prompt": {}, "compact_enabled": {}, + "byte_length": {}, "revision": {}, "rule_count": {}, "expose_server_prompt": {}, "compact_enabled": {}, "composition_mode": {}, "bundle_id": {}, "bundle_manifest_sha256": {}, "degraded": {}, "bundle_revision": {}, "bundle_archive_sha256": {}, "source_commit": {}, "archive_sha256": {}, "file_count": {}, "added_files": {}, "modified_files": {}, "deleted_files": {}, @@ -127,6 +127,7 @@ var auditSensitiveReads = map[string]string{ "GET /api/v1/admin/backups/s3-config": "admin.backups.s3_config.read", "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/: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", @@ -158,6 +159,8 @@ var auditActionOverrides = map[string]string{ "POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview", "POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete", "POST /api/v1/admin/system-prompts": "admin.system_prompts.create", + "PUT /api/v1/admin/system-prompts/config": "admin.system_prompts.config.update", + "POST /api/v1/admin/system-prompts/rules/preview/:account_id": "admin.system_prompts.rules.preview", "PATCH /api/v1/admin/system-prompts/:id": "admin.system_prompts.update", "DELETE /api/v1/admin/system-prompts/:id": "admin.system_prompts.delete", "POST /api/v1/admin/system-prompts/:id/duplicate": "admin.system_prompts.duplicate", @@ -192,10 +195,12 @@ var auditBodyOmittedRoutes = map[string]struct{}{ // larger than ordinary admin payloads. Their content is omitted entirely; // handlers attach only allowlisted hashes, lengths, IDs, revisions and flags. var auditPromptBodyOmittedRoutes = map[string]struct{}{ - "POST /api/v1/admin/system-prompts": {}, - "POST /api/v1/admin/system-prompts/:id/versions": {}, - "POST /api/v1/admin/system-prompts/preview/merge": {}, - "POST /api/v1/admin/system-prompts/preview/upstream": {}, + "PUT /api/v1/admin/system-prompts/config": {}, + "POST /api/v1/admin/system-prompts/rules/preview/:account_id": {}, + "POST /api/v1/admin/system-prompts": {}, + "POST /api/v1/admin/system-prompts/:id/versions": {}, + "POST /api/v1/admin/system-prompts/preview/merge": {}, + "POST /api/v1/admin/system-prompts/preview/upstream": {}, } // NewAuditLogMiddleware 创建审计中间件。 diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 516c262813ba..1c8877852190 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -150,6 +150,8 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing func TestSystemPromptAuditRoutesHaveStableActionsAndOmitPromptBodies(t *testing.T) { expectedActions := map[string]string{ "POST /api/v1/admin/system-prompts": "admin.system_prompts.create", + "PUT /api/v1/admin/system-prompts/config": "admin.system_prompts.config.update", + "POST /api/v1/admin/system-prompts/rules/preview/:account_id": "admin.system_prompts.rules.preview", "PATCH /api/v1/admin/system-prompts/:id": "admin.system_prompts.update", "DELETE /api/v1/admin/system-prompts/:id": "admin.system_prompts.delete", "POST /api/v1/admin/system-prompts/:id/duplicate": "admin.system_prompts.duplicate", @@ -167,6 +169,8 @@ func TestSystemPromptAuditRoutesHaveStableActionsAndOmitPromptBodies(t *testing. require.Equal(t, action, auditActionOverrides[route]) } for _, route := range []string{ + "PUT /api/v1/admin/system-prompts/config", + "POST /api/v1/admin/system-prompts/rules/preview/:account_id", "POST /api/v1/admin/system-prompts", "POST /api/v1/admin/system-prompts/:id/versions", "POST /api/v1/admin/system-prompts/preview/merge", @@ -176,6 +180,73 @@ func TestSystemPromptAuditRoutesHaveStableActionsAndOmitPromptBodies(t *testing. } } +func TestSystemPromptConfigAuditOmitsDraftBodies(t *testing.T) { + assertUnifiedSystemPromptAuditOmission(t, http.MethodPut, + "/api/v1/admin/system-prompts/config", "/api/v1/admin/system-prompts/config", + "admin.system_prompts.config.update", + `{"expected_revision":9,"policy":{"version":2,"rules":[{"id":"new","name":"audit-canary-name","role":"developer"}]},"contents":{"new":{"body":"audit-canary-draft-prompt"}}}`) +} + +func TestSystemPromptRulesPreviewAuditOmitsDraftAndClientInput(t *testing.T) { + assertUnifiedSystemPromptAuditOmission(t, http.MethodPost, + "/api/v1/admin/system-prompts/rules/preview/:account_id", "/api/v1/admin/system-prompts/rules/preview/7", + "admin.system_prompts.rules.preview", + `{"protocol":"responses","body":{"model":"test","instructions":"audit-canary-client-instructions","input":"audit-canary-client-input"},"contents":{"new":{"body":"audit-canary-draft-prompt"}}}`) +} + +func assertUnifiedSystemPromptAuditOmission(t *testing.T, method, route, path, action, payload string) { + t.Helper() + gin.SetMode(gin.TestMode) + repository := &auditCaptureRepository{} + auditService := service.NewAuditLogService(repository, nil) + auditService.Start() + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 77}) + c.Set(string(ContextKeyUserRole), "admin") + c.Next() + }) + router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService))) + router.Handle(method, route, func(c *gin.Context) { + body, err := c.GetRawData() + require.NoError(t, err) + require.Equal(t, payload, string(body), "audit omission must preserve the handler input") + SetAuditExtra(c, map[string]any{ + "revision": int64(10), "rule_count": 1, "result": "checked", + "error_code": "prompt_delivery_unsupported", + "body": "audit-canary-extra-prompt", + "role": "audit-canary-role", + "contents": map[string]any{"new": "audit-canary-extra-content"}, + }) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + request := httptest.NewRequest(method, path, bytes.NewBufferString(payload)) + 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, action, logs[0].Action) + require.Equal(t, route, logs[0].Path) + require.Equal(t, "", logs[0].RequestBody) + require.EqualValues(t, 10, logs[0].Extra["revision"]) + require.EqualValues(t, 1, logs[0].Extra["rule_count"]) + require.Equal(t, "prompt_delivery_unsupported", logs[0].Extra["error_code"]) + for _, field := range []string{"body", "role", "contents"} { + require.NotContains(t, logs[0].Extra, field) + } + encoded, err := json.Marshal(logs[0]) + require.NoError(t, err) + require.NotContains(t, string(encoded), "audit-canary") +} + func TestSystemPromptAuditNeverPersistsPromptOrPreviewInput(t *testing.T) { gin.SetMode(gin.TestMode) repository := &auditCaptureRepository{} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index da216e20750f..4695ea07e651 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -168,6 +168,8 @@ func registerCindyBalanceProbeRoutes(admin *gin.RouterGroup, h *handler.Handlers func registerSystemPromptRoutes(admin *gin.RouterGroup, h *handler.Handlers) { prompts := admin.Group("/system-prompts") { + 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) diff --git a/backend/internal/service/antigravity_credits_overages.go b/backend/internal/service/antigravity_credits_overages.go index ec3650859ce6..0c8fecbbdb7a 100644 --- a/backend/internal/service/antigravity_credits_overages.go +++ b/backend/internal/service/antigravity_credits_overages.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) @@ -185,7 +184,7 @@ func (s *AntigravityGatewayService) attemptCreditsOveragesRetry( logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 credit_overages_retry model=%s account=%d (injecting enabledCreditTypes)", p.prefix, modelKey, p.account.ID) - creditsReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, creditsBody) + creditsReq, err := s.buildPromptedAPIRequest(p.ctx, p.c, p.account, baseURL, p.action, p.accessToken, creditsBody) if err != nil { logger.LegacyPrintf("service.antigravity_gateway", "%s credit_overages_failed model=%s account=%d build_request_err=%v", p.prefix, modelKey, p.account.ID, err) diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 7f03536b7f0d..a096e8493d93 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -48,6 +48,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model") } + rememberPromptRequestedModel(c, body) originalModel := claudeReq.Model mappedModel := s.getMappedModelForThinkingLevel( account, diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 8763da57b0aa..6724722394f4 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -59,6 +59,7 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions( body []byte, _ *ParsedRequest, ) (*ForwardResult, error) { + rememberPromptRequestedModel(c, body) if err := s.validateAntigravityCompatAccount(c, account); err != nil { return nil, err } @@ -106,6 +107,7 @@ func (s *AntigravityGatewayService) ForwardAsResponses( body []byte, _ *ParsedRequest, ) (*ForwardResult, error) { + rememberPromptRequestedModel(c, body) if err := s.validateAntigravityCompatAccount(c, account); err != nil { return nil, err } diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index ce7fc69edfe1..34ea0b638cc3 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -42,6 +42,7 @@ func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOp } func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) { + rememberPromptRequestedModelName(c, originalModel) beginUpstreamResponseModelObservation(c) startTime := time.Now() forwardOpts := forwardGeminiOptions{} @@ -208,7 +209,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody) if err == nil { - fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped) + fallbackReq, err := s.buildPromptedAPIRequest(ctx, c, account, "", upstreamAction, accessToken, fallbackWrapped) if err == nil { fallbackResp, err := s.httpUpstream.Do(fallbackReq, proxyURL, account.ID, account.Concurrency) if err == nil && fallbackResp.StatusCode < 400 { @@ -410,7 +411,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co }) logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500)) MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, unwrappedForOps) + c.Data(resp.StatusCode, contentType, rewritePromptRulesStructuredEcho(c, unwrappedForOps, "gemini")) return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode) } diff --git a/backend/internal/service/antigravity_gateway_retry.go b/backend/internal/service/antigravity_gateway_retry.go index 78d46a4b23d6..8eed00b62653 100644 --- a/backend/internal/service/antigravity_gateway_retry.go +++ b/backend/internal/service/antigravity_gateway_retry.go @@ -214,7 +214,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam } // 智能重试:创建新请求 - retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + retryReq, err := s.buildPromptedAPIRequest(p.ctx, p.c, p.account, baseURL, p.action, p.accessToken, p.body) if err != nil { logger.LegacyPrintf("service.antigravity_gateway", "%s status=smart_retry_request_build_failed error=%v", p.prefix, err) p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) @@ -397,7 +397,7 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( totalWaited += waitDuration // 创建新请求 - retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + retryReq, err := s.buildPromptedAPIRequest(p.ctx, p.c, p.account, baseURL, p.action, p.accessToken, p.body) if err != nil { logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: request_build_failed error=%v", p.prefix, err) break @@ -536,7 +536,7 @@ urlFallbackLoop: default: } - upstreamReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + upstreamReq, err := s.buildPromptedAPIRequest(p.ctx, p.c, p.account, baseURL, p.action, p.accessToken, p.body) if err != nil { return nil, err } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index ad2f0b5f18d5..4011b3ee524a 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -121,14 +121,15 @@ func (e *PromptTooLongError) Error() string { // AntigravityGatewayService 处理 Antigravity 平台的 API 转发 type AntigravityGatewayService struct { - accountRepo AccountRepository - tokenProvider *AntigravityTokenProvider - rateLimitService *RateLimitService - httpUpstream HTTPUpstream - settingService *SettingService - cache GatewayCache // 用于模型级限流时清除粘性会话绑定 - schedulerSnapshot *SchedulerSnapshotService - internal500Cache Internal500CounterCache // INTERNAL 500 渐进惩罚计数器 + businessPromptService *BusinessSystemPromptService + accountRepo AccountRepository + tokenProvider *AntigravityTokenProvider + rateLimitService *RateLimitService + httpUpstream HTTPUpstream + settingService *SettingService + cache GatewayCache // 用于模型级限流时清除粘性会话绑定 + schedulerSnapshot *SchedulerSnapshotService + internal500Cache Internal500CounterCache // INTERNAL 500 渐进惩罚计数器 } func (s *AntigravityGatewayService) upstreamErrorBodyReadLimit() int64 { diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 59d4e459b7a6..4c5a003e7b4b 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -289,7 +289,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context firstTokenMs = &ms } - cw.Fprintf("data: %s\n\n", payload) + cw.Fprintf("data: %s\n\n", rewritePromptRulesStructuredEcho(c, []byte(payload), "gemini")) continue } @@ -520,7 +520,7 @@ returnResponse: if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } - c.Data(http.StatusOK, "application/json", respBody) + c.Data(http.StatusOK, "application/json", rewritePromptRulesStructuredEcho(c, respBody, "gemini")) return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil } diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index e6b9a73fa878..85e661746de1 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -40,6 +40,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. if strings.TrimSpace(claudeReq.Model) == "" { return nil, fmt.Errorf("missing model") } + rememberPromptRequestedModel(c, body) originalModel := claudeReq.Model // 构建上游请求 URL @@ -53,6 +54,12 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. body = sanitized } + var err error + body, _, err = s.businessPromptService.ApplyForSend(c, account, body, "messages", false) + if err != nil { + return nil, err + } + // 创建请求 req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) if err != nil { @@ -98,7 +105,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. // 透传上游错误 c.Header("Content-Type", resp.Header.Get("Content-Type")) c.Status(resp.StatusCode) - _, _ = c.Writer.Write(respBody) + _, _ = c.Writer.Write(rewritePromptRulesStructuredEcho(c, respBody, "messages")) return &ForwardResult{ Model: originalModel, @@ -135,7 +142,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. c.Header("Content-Type", resp.Header.Get("Content-Type")) c.Status(http.StatusOK) - _, _ = c.Writer.Write(respBody) + _, _ = c.Writer.Write(rewritePromptRulesStructuredEcho(c, respBody, "messages")) } // 构建计费结果 @@ -266,7 +273,7 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp s.extractSSEUsage(line, usage) // 透传行 - cw.Fprintf("%s\n", line) + cw.Fprintf("%s\n", rewritePromptRulesStructuredSSE(c, []byte(line), "messages")) case <-intervalCh: lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) diff --git a/backend/internal/service/business_system_prompt_account_scope_test.go b/backend/internal/service/business_system_prompt_account_scope_test.go index 456d12fc3335..cb86599f5564 100644 --- a/backend/internal/service/business_system_prompt_account_scope_test.go +++ b/backend/internal/service/business_system_prompt_account_scope_test.go @@ -2,611 +2,127 @@ package service import ( "context" - "encoding/json" - "fmt" - "net/http/httptest" "strings" "testing" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) -type accountScopedPromptInvoker struct { - module *promptpolicy.Module - rollout int - calls []extensionv1.Invocation -} - -// allows models a per-account prompt decision: 0 denies every account, 100 -// allows every account and anything between allows odd account IDs. -// Accountless previews are always allowed. -func (p *accountScopedPromptInvoker) allows(accountID int64) bool { - switch { - case accountID == 0 || p.rollout >= 100: - return true - case p.rollout <= 0: - return false - default: - return accountID%2 == 1 - } -} - -// invoke denies prompt plans for accounts outside the modeled scope, which -// covers API-key accounts only. -func (p *accountScopedPromptInvoker) invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation != "prompt.plan" { - return p.module.Invoke(ctx, in) - } - p.calls = append(p.calls, in) - if accountType := gjson.GetBytes(in.Payload, "target.account_type").String(); !p.allows(in.AccountID) || accountType != AccountTypeAPIKey { - return extensionv1.Result{}, ErrExtensionOperationDisabled - } - return p.module.Invoke(ctx, in) -} - -func newAccountScopedPromptGateway(t *testing.T, rollout int) (*OpenAIGatewayService, *accountScopedPromptInvoker, *fakeBusinessSystemPromptStore) { - t.Helper() - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "account-scoped-server"}} - policy := NewBusinessSystemPromptService(store, nil) - require.NoError(t, policy.Initialize(context.Background())) - invoker := &accountScopedPromptInvoker{module: promptpolicy.New(), rollout: rollout} - previous := invokePromptSkills - invokePromptSkills = invoker.invoke - t.Cleanup(func() { invokePromptSkills = previous }) - return &OpenAIGatewayService{businessPromptService: policy}, invoker, store -} - -func promptScopeAccounts(t *testing.T) (*Account, *Account) { - t.Helper() - return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} -} - -func TestPromptRealAccountZeroRolloutRejected(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 0) - account, _ := promptScopeAccounts(t) - input := []byte(`{"instructions":"client","input":"customer history"}`) - output, application, err := gateway.applyBusinessSystemPrompt(input, account, BusinessSystemPromptProtocolResponses, false) +type promptSendBindingRepository struct { + AccountRepository + accounts map[int64]*Account + reads []int64 +} + +func (r *promptSendBindingRepository) GetByID(_ context.Context, id int64) (*Account, error) { + r.reads = append(r.reads, id) + return r.accounts[id], nil +} + +func TestPromptAccountSelectionUsesFreshBindingAndFrozenContent(t *testing.T) { + policy := newPromptRulesGatewayPolicy("system", "control_prepend") + included := businessSystemPromptAPIKeyAccount(true) + included.ID = 1 + excluded := businessSystemPromptAPIKeyAccount(true) + excluded.ID = 2 + excluded.Extra[PromptAccountBindingExtraKey] = extensionv1.PromptAccountBinding{Mode: "off"} + repo := &promptSendBindingRepository{accounts: map[int64]*Account{1: included, 2: excluded}} + policy.SetAccountRepository(repo) + gateway := &OpenAIGatewayService{businessPromptService: policy} + original := []byte(`{"model":"gpt-5.4","input":[{"role":"system","content":"site-rule-content"},{"role":"user","content":"customer"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", original) + sent, first, err := policy.ApplyForSend(c, included, original, "responses", false) require.NoError(t, err) - require.False(t, application.Applied) - require.Equal(t, input, output) - require.Len(t, invoker.calls, 1) - require.Equal(t, account.ID, invoker.calls[0].AccountID) -} - -func TestPromptAccountFailoverCannotReusePreviousDecision(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - input := []byte(`{"instructions":"client","input":"customer history"}`) - _, first, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, BusinessSystemPromptProtocolResponses, false) + require.Equal(t, 2, strings.Count(string(sent), "site-rule-content")) + require.Equal(t, int64(8), first.Revision) + + changed := promptRulesSnapshotForTest(BusinessSystemPromptSnapshot{Revision: 9, Enabled: true, Body: "next-revision"}) + policy.snapshot.Store(&changed) + staleSelection := *excluded + staleSelection.Extra = nil + clean, off, err := policy.ApplyForSend(c, &staleSelection, original, "responses", false) require.NoError(t, err) - require.True(t, first.Applied) - output, second, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, excluded, BusinessSystemPromptProtocolResponses, false) + require.False(t, off.Applied) + require.Equal(t, original, clean, "failover starts from the untouched customer sequence") + require.Equal(t, int64(8), off.Revision) + _, oldResponse := businessSystemPromptApplicationFromRequest(c, "chat") + require.False(t, oldResponse) + + excluded.Extra[PromptAccountBindingExtraKey] = extensionv1.PromptAccountBinding{Mode: "custom", RuleIDs: []string{"site"}} + sent, again, err := gateway.applyBusinessSystemPromptForRequest(c, original, &staleSelection, "responses", false) require.NoError(t, err) - require.False(t, second.Applied, "the previous account's input-hash cache hit must not grant the new account") - require.Equal(t, "client", gjson.GetBytes(output, "instructions").String()) - require.Len(t, invoker.calls, 2) - require.Equal(t, included.ID, invoker.calls[0].AccountID) - require.Equal(t, excluded.ID, invoker.calls[1].AccountID) -} - -func TestPromptRealAccountRolloutAndAccountlessPreview(t *testing.T) { - for _, rollout := range []int{50, 100} { - t.Run(fmt.Sprint(rollout), func(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, rollout) - included, excluded := promptScopeAccounts(t) - for _, account := range []*Account{included, excluded} { - input := []byte(`{"instructions":"client","input":"private-customer-history"}`) - _, application, err := gateway.applyBusinessSystemPrompt(input, account, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - require.Equal(t, invoker.allows(account.ID), application.Applied) - call := invoker.calls[len(invoker.calls)-1] - require.Equal(t, account.ID, call.AccountID) - require.Equal(t, account.ID, gjson.GetBytes(call.Payload, "target.account_id").Int()) - require.NotContains(t, string(call.Payload), "private-customer-history") + require.True(t, again.Applied) + require.Equal(t, first.Revision, again.Revision) + require.NotContains(t, string(sent), "next-revision") + require.Equal(t, []int64{1, 2, 2}, repo.reads) + require.Equal(t, 1, strings.Count(string(original), "site-rule-content")) +} + +func TestPromptRulePlatformUsesProviderRatherThanWireProtocol(t *testing.T) { + policy := newPromptRulesGatewayPolicy("auto", "control_append") + body := []byte(`{"model":"gpt-5.4","instructions":"client","input":"history"}`) + for _, platform := range []string{PlatformCindy, PlatformGrok} { + t.Run(platform, func(t *testing.T) { + account := businessSystemPromptAPIKeyAccount(true) + account.Platform, account.WirePlatform = platform, WirePlatformOpenAI + c, _ := newBusinessSystemPromptGinContext("/v1/responses", body) + wire, app, err := policy.ApplyForSend(c, account, body, "responses", false) + require.NoError(t, err) + require.Equal(t, platform == PlatformCindy, app.Applied) + if platform == PlatformGrok { + require.Equal(t, body, wire) + require.Equal(t, "platform_scope", app.RulesPlan.Skipped[0].Reason) } }) } - t.Run("accountless_preview", func(t *testing.T) { - _, invoker, _ := newAccountScopedPromptGateway(t, 0) - input := []byte(`{"instructions":"client","input":"preview"}`) - _, application, err := ApplyBusinessSystemPromptToJSON(input, BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "preview-server"}, BusinessSystemPromptTarget{Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Protocol: BusinessSystemPromptProtocolResponses}) - require.NoError(t, err) - require.True(t, application.Applied, "global preview keeps its accountless semantics") - require.Zero(t, invoker.calls[0].AccountID) - require.False(t, gjson.GetBytes(invoker.calls[0].Payload, "target.account_id").Exists()) - }) -} - -func TestPromptAccountChangeRestoresOnlyOwnedCarrier(t *testing.T) { - for _, carrier := range []string{"responses", "chat", "chat_instructions"} { - for _, form := range []string{"original", "output", "wire_rewrite"} { - t.Run(carrier+"/"+form, func(t *testing.T) { - gateway, invoker, store := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - protocol := BusinessSystemPromptProtocolResponses - input := []byte(`{"instructions":" client ","input":"private-history","model":"before"}`) - if carrier != "responses" { - protocol = BusinessSystemPromptProtocolChat - // This customer system message deliberately equals our policy. - input = []byte(`{"messages":[{"role":"system","content":"account-scoped-server"},{"role":"developer","content":"client-control"},{"role":"user","content":"private-history"}],"model":"before"}`) - if carrier == "chat_instructions" { - var err error - input, err = sjson.SetBytes(input, "instructions", " client ") - require.NoError(t, err) - } - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - first, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, protocol, false) - require.NoError(t, err) - require.True(t, application.Applied) - store.loaded = BusinessSystemPromptSnapshot{Revision: 2, Enabled: true, Body: "new-unfrozen-server"} - require.NoError(t, gateway.businessPromptService.Reload(context.Background())) - retry := first - switch form { - case "original": - retry = input - case "wire_rewrite": - retry, err = sjson.SetBytes(retry, "model", "after") - require.NoError(t, err) - } - clean, rejected, err := gateway.applyBusinessSystemPromptForRequest(ctx, retry, excluded, protocol, false) - require.NoError(t, err) - require.False(t, rejected.Applied) - if carrier == "chat" { - require.JSONEq(t, gjson.GetBytes(input, "messages").Raw, gjson.GetBytes(clean, "messages").Raw) - require.Equal(t, "account-scoped-server", gjson.GetBytes(clean, "messages.0.content").String()) - } else { - require.Equal(t, gjson.GetBytes(input, "instructions").Raw, gjson.GetBytes(clean, "instructions").Raw) - } - if form == "wire_rewrite" { - require.Equal(t, "after", gjson.GetBytes(clean, "model").String()) - } - _, frozen, err := gateway.applyBusinessSystemPromptForRequest(ctx, clean, included, protocol, false) - require.NoError(t, err) - require.True(t, frozen.Applied) - require.Equal(t, int64(1), frozen.Revision) - require.Equal(t, "account-scoped-server", frozen.ServerInstructions) - require.Len(t, invoker.calls, 3) - for _, call := range invoker.calls { - require.Equal(t, int64(1), gjson.GetBytes(call.Payload, "snapshot.revision").Int()) - require.NotContains(t, string(call.Payload), "private-history") - } - }) - } - } -} - -func TestPromptSameAccountCacheRechecksRevocation(t *testing.T) { - for _, revoke := range []string{"denied"} { - for _, form := range []string{"input", "output", "cache_key_rewrite"} { - t.Run(revoke+"/"+form, func(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) - account, _ := promptScopeAccounts(t) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - input := []byte(`{"instructions":"client","input":"history","prompt_cache_key":"client-key"}`) - body, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, account, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - require.True(t, application.Applied) - switch form { - case "input": - body = input - case "cache_key_rewrite": - body, err = rewriteBusinessSystemPromptCacheKey(ctx, body, application) - require.NoError(t, err) - } - invoker.rollout = 0 - clean, denied, err := gateway.applyBusinessSystemPromptForRequest(ctx, body, account, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - require.False(t, denied.Applied) - require.Equal(t, "client", gjson.GetBytes(clean, "instructions").String()) - require.Len(t, invoker.calls, 2) - require.Equal(t, account.ID, invoker.calls[1].AccountID) - }) - } - } } -func TestPromptUnknownCarrierFailsClosedWithoutDeletingClientMessages(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - t.Run(protocol, func(t *testing.T) { - gateway, _, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - input := []byte(`{"instructions":"client","input":"history"}`) - if protocol == BusinessSystemPromptProtocolChat { - input = []byte(`{"messages":[{"role":"system","content":"account-scoped-server"},{"role":"user","content":"history"}]}`) - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - body, _, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, protocol, false) - require.NoError(t, err) - if protocol == BusinessSystemPromptProtocolResponses { - body, err = sjson.SetBytes(body, "instructions", "client\n\naccount-scoped-server\nunknown-transform") - } else { - body, err = sjson.SetBytes(body, "messages.-1", map[string]string{"role": "user", "content": "unknown-transform"}) - } - require.NoError(t, err) - before := append([]byte(nil), body...) - output, _, err := gateway.applyBusinessSystemPromptForRequest(ctx, body, excluded, protocol, false) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, output) - require.Equal(t, before, body) - }) +func TestPromptCleanSourceFallbackDoesNotInvokePluginOrCarryPreviousOutput(t *testing.T) { + previous := invokePromptSkills + invokePromptSkills = func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { + t.Fatal("ordinary inline prompt reached plugin runtime") + return extensionv1.Result{}, nil } -} - -func TestPromptUndoMetadataIsBoundedAndHistoryStaysOutOfRPC(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - client := strings.Repeat("private-client-control", businessSystemPromptRestoreMaxBytes/10) - history := strings.Repeat("private-history", extensionv1.MaxPayloadBytes/10) - input, err := json.Marshal(map[string]string{"instructions": client, "input": history}) + t.Cleanup(func() { invokePromptSkills = previous }) + policy := newPromptRulesGatewayPolicy("auto", "control_append") + gateway := &OpenAIGatewayService{businessPromptService: policy} + account := businessSystemPromptAPIKeyAccount(true) + body := []byte(`{"model":"gpt-5.4","instructions":"client","input":"history"}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", body) + wire, app, err := policy.ApplyForSend(c, account, body, "responses", false) require.NoError(t, err) - output, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, BusinessSystemPromptProtocolResponses, false) + require.True(t, app.Applied) + again, _, err := policy.ApplyForSend(c, account, body, "responses", false) require.NoError(t, err) - require.True(t, application.Applied) - value, _ := ctx.Get(businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolResponses)) - state, ok := value.(businessSystemPromptRequestState) - require.True(t, ok) - require.Empty(t, state.undo.instructions) - require.False(t, state.undo.restorable) - rejected, _, err := gateway.applyBusinessSystemPromptForRequest(ctx, output, excluded, BusinessSystemPromptProtocolResponses, false) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, rejected) - // Passing the known original remains safe without retaining a second copy. - clean, denied, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, excluded, BusinessSystemPromptProtocolResponses, false) + require.Equal(t, wire, again) + chat := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"client"},{"role":"user","content":"history"}]}`) + converted, applied, err := policy.ApplyForSend(c, account, chat, "chat", false) require.NoError(t, err) - require.False(t, denied.Applied) - require.Equal(t, input, clean) - for _, call := range invoker.calls { - require.Less(t, len(call.Payload), extensionv1.MaxPayloadBytes) - require.NotContains(t, string(call.Payload), "private-client-control") - require.NotContains(t, string(call.Payload), "private-history") - } -} - -func TestPromptProtocolConversionRestoresCarrierBeforeAccountFailover(t *testing.T) { - for _, direction := range []string{"responses_to_chat", "chat_to_responses"} { - t.Run(direction, func(t *testing.T) { - gateway, invoker, store := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - includedID, excludedID := included.ID, excluded.ID - included, excluded = businessSystemPromptAPIKeyAccount(true), businessSystemPromptAPIKeyAccount(true) - included.ID, excluded.ID = includedID, excludedID - protocol, path := BusinessSystemPromptProtocolResponses, "/v1/responses" - input := []byte(`{"model":"gpt-5.4","stream":false,"instructions":"client-control","input":"private-adapter-history"}`) - if direction == "chat_to_responses" { - protocol, path = BusinessSystemPromptProtocolChat, "/v1/chat/completions" - input = []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"system","content":"client-control"},{"role":"user","content":"private-adapter-history"}]}`) - } - ctx, _ := newBusinessSystemPromptGinContext(path, input) - body, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, protocol, false) - require.NoError(t, err) - require.True(t, application.Applied) - store.loaded = BusinessSystemPromptSnapshot{Revision: 2, Enabled: true, Body: "new-unfrozen-server"} - require.NoError(t, gateway.businessPromptService.Reload(context.Background())) - upstream := &businessCacheStrictUpstream{} - gateway.cfg, gateway.httpUpstream = businessSystemPromptTestConfig(), upstream - if direction == "responses_to_chat" { - _, err = gateway.forwardResponsesViaRawChatCompletions(context.Background(), ctx, excluded, body, false) - } else { - _, err = gateway.ForwardAsChatCompletions(context.Background(), ctx, excluded, body, "", "") - } - require.NoError(t, err) - require.Len(t, upstream.bodies, 1) - require.NotContains(t, string(upstream.lastBody), "account-scoped-server") - require.NotContains(t, string(upstream.lastBody), "new-unfrozen-server") - require.Contains(t, string(upstream.lastBody), "client-control") - require.Contains(t, string(upstream.lastBody), "private-adapter-history") - last := invoker.calls[len(invoker.calls)-1] - require.Equal(t, excluded.ID, last.AccountID) - require.Equal(t, int64(1), gjson.GetBytes(last.Payload, "snapshot.revision").Int()) - require.NotContains(t, string(last.Payload), "private-adapter-history") - }) - } -} - -func TestPromptDuplicateCarrierCannotAuthorizeUndo(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - t.Run(protocol, func(t *testing.T) { - gateway, _, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - input := []byte(`{"instructions":"first-client","instructions":"last-client","input":"history"}`) - if protocol == BusinessSystemPromptProtocolChat { - input = []byte(`{"messages":[{"role":"system","content":"first-client-control"},{"role":"user","content":"first-history"}],"messages":[{"role":"user","content":"last-history"}]}`) - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - output, first, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, protocol, false) - require.NoError(t, err) - require.True(t, first.Applied) - before := append([]byte(nil), output...) - denied, _, err := gateway.applyBusinessSystemPromptForRequest(ctx, output, excluded, protocol, false) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, denied) - require.Equal(t, before, output, "ambiguous duplicate fields must not delete a customer message") - original, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, excluded, protocol, false) - require.NoError(t, err) - require.False(t, application.Applied) - require.Equal(t, input, original) - }) - } -} - -func TestPromptCachedTargetSeparatesAccountTypeAndCompact(t *testing.T) { - for _, change := range []string{"account_type", "compact"} { - t.Run(change, func(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) - account, _ := promptScopeAccounts(t) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - input := []byte(`{"instructions":"client","input":"history"}`) - output, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, account, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - require.True(t, application.Applied) - next := *account - compact := change == "compact" - if !compact { - next.Type = AccountTypeOAuth - } - clean, current, err := gateway.applyBusinessSystemPromptForRequest(ctx, output, &next, BusinessSystemPromptProtocolResponses, compact) - require.NoError(t, err) - require.False(t, current.Applied) - require.Equal(t, "client", gjson.GetBytes(clean, "instructions").String()) - require.Len(t, invoker.calls, 2) - require.Equal(t, next.Type, gjson.GetBytes(invoker.calls[1].Payload, "target.account_type").String()) - require.Equal(t, compact, gjson.GetBytes(invoker.calls[1].Payload, "target.compact").Bool()) - }) - } -} - -func TestPromptAccountChangeDoesNotReuseOtherProtocolResponseMetadata(t *testing.T) { - gateway, _, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - _, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, []byte(`{"instructions":"client","input":"history"}`), included, BusinessSystemPromptProtocolResponses, false) + require.Equal(t, 1, strings.Count(string(converted), "site-rule-content")) + require.Equal(t, app.Revision, applied.Revision) + require.Equal(t, "client", gjson.GetBytes(converted, "messages.0.content").String()) + require.Equal(t, "site-rule-content", gjson.GetBytes(converted, "messages.1.content").String()) + require.Equal(t, body, gateway.rewriteBusinessSystemPromptJSONForRequest(c, body, "responses")) +} + +func TestPromptEchoProofPreservesIdenticalClientMessageAtNewPositions(t *testing.T) { + policy := newPromptRulesGatewayPolicy("developer", "before_last_user") + account := businessSystemPromptAPIKeyAccount(true) + clientText := strings.Repeat("customer history ", 10000) + body := []byte(`{"model":"gpt-5.4","input":[{"role":"developer","content":"site-rule-content"},{"role":"user","content":"` + clientText + `"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", body) + gateway := &OpenAIGatewayService{businessPromptService: policy} + wire, _, err := policy.ApplyForSend(c, account, body, "responses", false) require.NoError(t, err) - require.True(t, application.Applied) - _, denied, err := gateway.applyBusinessSystemPromptForRequest(ctx, []byte(`{"messages":[{"role":"user","content":"history"}]}`), excluded, BusinessSystemPromptProtocolChat, false) - require.NoError(t, err) - require.False(t, denied.Applied) - _, present := businessSystemPromptApplicationFromRequest(ctx, BusinessSystemPromptProtocolResponses) - require.False(t, present) - echo := []byte(`{"instructions":"client\n\naccount-scoped-server","output":[]}`) - require.Equal(t, echo, gateway.rewriteBusinessSystemPromptJSONForAnyRequest(ctx, echo), "an excluded account's response must not be scrubbed using an older account's application") -} - -func TestPromptDeniedRetriesStillRecognizeEarlierOwnedOutput(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - t.Run(protocol, func(t *testing.T) { - gateway, _, _ := newAccountScopedPromptGateway(t, 50) - included, excluded := promptScopeAccounts(t) - input := []byte(`{"instructions":"client","input":"history"}`) - field := "instructions" - if protocol == BusinessSystemPromptProtocolChat { - field = "messages" - input = []byte(`{"messages":[{"role":"system","content":"account-scoped-server"},{"role":"user","content":"history"}]}`) - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - earlier, applied, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, included, protocol, false) - require.NoError(t, err) - require.True(t, applied.Applied) - _, denied, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, excluded, protocol, false) - require.NoError(t, err) - require.False(t, denied.Applied) - clean, stillDenied, err := gateway.applyBusinessSystemPromptForRequest(ctx, earlier, excluded, protocol, false) - require.NoError(t, err) - require.False(t, stillDenied.Applied) - require.JSONEq(t, gjson.GetBytes(input, field).Raw, gjson.GetBytes(clean, field).Raw, "a denied cache entry must not forget previously injected output") - }) - } -} - -func TestPromptPlatformChangeUsesBoundedNativeCarrierProof(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - for _, form := range []string{"owned_output", "clean_changed", "unproven_server", "duplicate", "non_string", "oversized_unknown"} { - t.Run(protocol+"/"+form, func(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) - account, _ := promptScopeAccounts(t) - input := []byte(`{"instructions":"client-control","input":"history"}`) - field := "instructions" - if protocol == BusinessSystemPromptProtocolChat { - field = "messages" - input = []byte(`{"messages":[{"role":"system","content":"client-control"},{"role":"user","content":"history"}]}`) - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - output, applied, err := gateway.applyBusinessSystemPromptForRequest(ctx, input, account, protocol, false) - require.NoError(t, err) - require.True(t, applied.Applied) - body := output - if protocol == BusinessSystemPromptProtocolResponses { - switch form { - case "clean_changed": - body = []byte(`{"instructions":"grok-client","input":"account-scoped-server"}`) - case "unproven_server": - body = []byte(`{"instructions":"grok-client\n\naccount-scoped-server","input":"history"}`) - case "duplicate": - body = []byte(`{"instructions":"grok-client","instructions":"account-scoped-server","input":"history"}`) - case "non_string": - body = []byte(`{"instructions":[{"type":"text","text":"grok-client"}],"input":"history"}`) - case "oversized_unknown": - body, err = json.Marshal(map[string]string{"instructions": strings.Repeat("other-client", businessSystemPromptRestoreMaxBytes), "input": "history"}) - require.NoError(t, err) - } - } else { - switch form { - case "clean_changed": - body = []byte(`{"messages":[{"role":"system","content":"grok-client"},{"role":"user","content":"account-scoped-server"}]}`) - case "unproven_server": - body = []byte(`{"messages":[{"role":"system","content":"account-scoped-server"},{"role":"user","content":"other-client-history"}]}`) - case "duplicate": - body = []byte(`{"messages":[{"role":"system","content":"grok-client"}],"messages":[{"role":"system","content":"account-scoped-server"}]}`) - case "non_string": - body = []byte(`{"messages":[{"role":"system","content":[{"type":"text","text":"grok-client"}]}]}`) - case "oversized_unknown": - body, err = json.Marshal(map[string]any{"messages": []map[string]string{{"role": "user", "content": strings.Repeat("other-history", businessSystemPromptRestoreMaxBytes)}}}) - require.NoError(t, err) - } - } - before := append([]byte(nil), body...) - grok := &Account{ID: account.ID + 1, Platform: PlatformGrok, Type: AccountTypeAPIKey} - clean, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, body, grok, protocol, false) - switch form { - case "owned_output": - require.NoError(t, err) - require.JSONEq(t, gjson.GetBytes(input, field).Raw, gjson.GetBytes(clean, field).Raw) - case "clean_changed": - require.NoError(t, err) - require.Equal(t, before, clean, "independent customer control and user history remain unchanged") - default: - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, clean) - } - require.False(t, application.Applied) - require.Equal(t, before, body) - require.Len(t, invoker.calls, 1, "an excluded platform performs no prompt-policy RPC") - }) - } - } -} - -func TestPromptUndoTracksTrimmedTextActuallyInserted(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - t.Run(protocol, func(t *testing.T) { - carrier := BusinessSystemPromptCarrierInstructions - input := []byte(`{"instructions":"client","input":"history"}`) - unknown := []byte(`{"instructions":"different-client\n\napproved-server","input":"history"}`) - if protocol == BusinessSystemPromptProtocolChat { - carrier = BusinessSystemPromptCarrierSystemMessage - input = []byte(`{"messages":[{"role":"user","content":"history"}]}`) - unknown = []byte(`{"messages":[{"role":"system","content":"approved-server"},{"role":"user","content":"different-history"}]}`) - } - // The public Application contract does not require trimmed text. - // Both real local appliers nevertheless trim before insertion. - snapshot := BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: " \tapproved-server\n "} - application := BusinessSystemPromptApplication{Applied: true, Carrier: carrier, ServerInstructions: snapshot.Body, Revision: 1} - output, application, err := applyBusinessSystemPromptApplication(input, application) - require.NoError(t, err) - if protocol == BusinessSystemPromptProtocolResponses { - require.Equal(t, "client\n\napproved-server", gjson.GetBytes(output, "instructions").String()) - } else { - require.Equal(t, "approved-server", gjson.GetBytes(output, "messages.0.content").String()) - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - target := BusinessSystemPromptTarget{AccountID: 1, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Protocol: protocol} - ctx.Set(businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol), cacheBusinessSystemPromptState(input, output, snapshot, target, application)) - before := append([]byte(nil), unknown...) - clean, err := restoreBusinessSystemPromptForExcludedTarget(ctx, unknown, protocol) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, clean) - require.Equal(t, before, unknown) - }) - } -} - -func TestPromptCarrierTransitionCannotForgetEarlierAppliedOutput(t *testing.T) { - for _, direction := range []string{"instructions_to_messages", "messages_to_instructions"} { - for _, destination := range []string{"grok", "denied"} { - t.Run(direction+"/"+destination, func(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) - firstAccount, secondAccount := promptScopeAccounts(t) - withInstructions := []byte(`{"instructions":"client-control","messages":[{"role":"user","content":"history"}]}`) - withoutInstructions := []byte(`{"messages":[{"role":"user","content":"history"}]}`) - firstInput, secondInput := withInstructions, withoutInstructions - if direction == "messages_to_instructions" { - firstInput, secondInput = withoutInstructions, withInstructions - } - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - olderOutput, first, err := gateway.applyBusinessSystemPromptForRequest(ctx, firstInput, firstAccount, BusinessSystemPromptProtocolChat, false) - require.NoError(t, err) - require.True(t, first.Applied) - _, second, err := gateway.applyBusinessSystemPromptForRequest(ctx, secondInput, secondAccount, BusinessSystemPromptProtocolChat, false) - require.NoError(t, err) - require.True(t, second.Applied) - require.NotEqual(t, first.Carrier, second.Carrier, "the official planner selects by HasInstructions") - require.Equal(t, first.ServerInstructions, second.ServerInstructions, "no arbitrary change to approved content is assumed") - require.Equal(t, first.Revision, second.Revision) - cached, _ := ctx.Get(businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolChat)) - state, ok := cached.(businessSystemPromptRequestState) - require.True(t, ok) - require.True(t, state.undo.present && state.otherUndo.present) - require.NotEqual(t, state.undo.carrier, state.otherUndo.carrier) - require.LessOrEqual(t, len(state.undo.instructions)+len(state.otherUndo.instructions), businessSystemPromptRestoreMaxBytes) - require.False(t, state.historyUncertain, "official frozen text is identical across the two native carriers") - other := &Account{ID: secondAccount.ID + 10000, Platform: PlatformGrok, Type: AccountTypeAPIKey} - if destination == "denied" { - other.Platform = PlatformOpenAI - invoker.rollout = 0 - } - clean, application, err := gateway.applyBusinessSystemPromptForRequest(ctx, olderOutput, other, BusinessSystemPromptProtocolChat, false) - if err != nil { - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, clean) - } else { - require.Equal(t, gjson.GetBytes(firstInput, "instructions").Raw, gjson.GetBytes(clean, "instructions").Raw) - require.JSONEq(t, gjson.GetBytes(firstInput, "messages").Raw, gjson.GetBytes(clean, "messages").Raw) - } - require.False(t, application.Applied) - }) - } - } -} - -func TestPromptChangedServerHistoryRejectsOnlyUnprovenBodies(t *testing.T) { - for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat} { - t.Run(protocol, func(t *testing.T) { - carrier := BusinessSystemPromptCarrierInstructions - input := []byte(`{"instructions":"client-control","input":"history"}`) - unknownClean := []byte(`{"instructions":"grok-client","input":"other-history"}`) - if protocol == BusinessSystemPromptProtocolChat { - carrier = BusinessSystemPromptCarrierSystemMessage - input = []byte(`{"messages":[{"role":"user","content":"history"}]}`) - unknownClean = []byte(`{"messages":[{"role":"system","content":"grok-client"},{"role":"user","content":"other-history"}]}`) - } - snapshot := BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "approved-server"} - target := BusinessSystemPromptTarget{AccountID: 1, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Protocol: protocol} - firstPlan := BusinessSystemPromptApplication{Applied: true, Carrier: carrier, ServerInstructions: "approved-server", Revision: 1} - olderOutput, first, err := applyBusinessSystemPromptApplication(input, firstPlan) - require.NoError(t, err) - previous := cacheBusinessSystemPromptState(input, olderOutput, snapshot, target, first) - // Defensive SDK-result test, not an assertion that the official - // planner changes approved text. The public result structure and - // host decoder do not impose equality with Snapshot.Body. - nextPlan := firstPlan - nextPlan.ServerInstructions = "[approved-server]" - latestOutput, latest, err := applyBusinessSystemPromptApplication(input, nextPlan) - require.NoError(t, err) - next := inheritBusinessSystemPromptProvenance(cacheBusinessSystemPromptState(input, latestOutput, snapshot, target, latest), previous) - require.True(t, next.historyUncertain) - require.False(t, next.otherUndo.present, "same-carrier text revisions do not grow a history list") - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - ctx.Set(businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol), next) - for _, unproven := range [][]byte{olderOutput, unknownClean} { - clean, err := restoreBusinessSystemPromptForExcludedTarget(ctx, unproven, protocol) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, clean) - } - original, err := restoreBusinessSystemPromptForExcludedTarget(ctx, input, protocol) - require.NoError(t, err) - require.Equal(t, input, original) - restored, err := restoreBusinessSystemPromptForExcludedTarget(ctx, latestOutput, protocol) - require.NoError(t, err) - require.JSONEq(t, string(input), string(restored)) - }) - } -} - -func TestPromptAppliedMetadataWithoutUndoIsNotCleanProof(t *testing.T) { - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - ctx.Set(businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolResponses), businessSystemPromptRequestState{ - snapshot: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "approved-server"}, - application: BusinessSystemPromptApplication{Applied: true, Carrier: BusinessSystemPromptCarrierInstructions, ServerInstructions: "approved-server", Revision: 1}, - }) - input := []byte(`{"instructions":"client\n\napproved-server","input":"history"}`) - output, err := restoreBusinessSystemPromptForExcludedTarget(ctx, input, BusinessSystemPromptProtocolResponses) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) - require.Nil(t, output) + stateRaw, ok := businessSystemPromptRequestGet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, "responses")) + require.True(t, ok) + state := stateRaw.(businessSystemPromptRequestState) + require.Len(t, state.rulesUndo, 1) + require.Empty(t, state.rulesUndo[0].scalar, "large client histories are not copied into echo metadata") + require.Equal(t, []int{1}, state.rulesUndo[0].indices) + restored := gateway.rewriteBusinessSystemPromptJSONForRequest(c, wire, "responses") + require.JSONEq(t, string(body), string(restored)) + require.Equal(t, 1, strings.Count(string(restored), "site-rule-content")) } diff --git a/backend/internal/service/business_system_prompt_cache_identity_test.go b/backend/internal/service/business_system_prompt_cache_identity_test.go index a40ba422fb9d..d745eb31797b 100644 --- a/backend/internal/service/business_system_prompt_cache_identity_test.go +++ b/backend/internal/service/business_system_prompt_cache_identity_test.go @@ -118,9 +118,9 @@ func TestBusinessSystemPromptCacheIdentityTreatsClientKeysAsOpaque(t *testing.T) } func TestBusinessSystemPromptCacheIdentityFrozenRetryAndFallback(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "server"}} + store := &fakeBusinessSystemPromptStore{loaded: promptRulesSnapshotForTest(BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "server"})} policy := NewBusinessSystemPromptService(store, nil) - require.NoError(t, policy.Initialize(context.Background())) + policy.snapshot.Store(&store.loaded) svc := &OpenAIGatewayService{businessPromptService: policy} account := businessSystemPromptAPIKeyAccount(true) original := []byte(`{"instructions":"client","prompt_cache_key":"seed","input":[]}`) @@ -130,9 +130,9 @@ func TestBusinessSystemPromptCacheIdentityFrozenRetryAndFallback(t *testing.T) { body, err = rewriteBusinessSystemPromptCacheKey(c, body, application) require.NoError(t, err) want := gjson.GetBytes(body, "prompt_cache_key").String() - store.loaded = BusinessSystemPromptSnapshot{Revision: 2, Enabled: true, Body: "new-server"} - require.NoError(t, policy.Reload(context.Background())) - for _, retryBody := range [][]byte{original, body} { + store.loaded = promptRulesSnapshotForTest(BusinessSystemPromptSnapshot{Revision: 2, Enabled: true, Body: "new-server"}) + policy.snapshot.Store(&store.loaded) + for _, retryBody := range [][]byte{original, original} { account.ID++ // Failover must not create a new business-prompt identity. retried, frozen, err := svc.applyBusinessSystemPromptForRequest(c, retryBody, account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) @@ -141,12 +141,13 @@ func TestBusinessSystemPromptCacheIdentityFrozenRetryAndFallback(t *testing.T) { require.Equal(t, want, gjson.GetBytes(retried, "prompt_cache_key").String()) require.Equal(t, int64(1), frozen.Revision) } - fallback := []byte(fmt.Sprintf(`{"prompt_cache_key":%q,"messages":[{"role":"user","content":"continue"}]}`, want)) + fallback := []byte(`{"prompt_cache_key":"seed","messages":[{"role":"user","content":"continue"}]}`) fallback, frozen, err := svc.applyBusinessSystemPromptForRequest(c, fallback, account, BusinessSystemPromptProtocolChat, false) require.NoError(t, err) fallback, err = rewriteBusinessSystemPromptCacheKey(c, fallback, frozen) require.NoError(t, err) - require.Equal(t, want, gjson.GetBytes(fallback, "prompt_cache_key").String()) + require.Equal(t, deriveBusinessSystemPromptCacheKey(c, "seed", frozen), gjson.GetBytes(fallback, "prompt_cache_key").String()) + require.NotEqual(t, want, gjson.GetBytes(fallback, "prompt_cache_key").String(), "different final carriers have distinct prompt identities") fresh, _ := newBusinessSystemPromptGinContext("/v1/responses", original) freshBody, freshApplication, err := svc.applyBusinessSystemPromptForRequest(fresh, original, account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) @@ -353,14 +354,14 @@ func TestBusinessSystemPromptCacheIdentityPassthroughHeaderPriority(t *testing.T c.Request.Header.Set("session_id", "explicit-session") c.Request.Header.Set("conversation_id", "explicit-conversation") } - body, application, err := svc.applyBusinessSystemPromptForRequest(c, body, account, BusinessSystemPromptProtocolResponses, false) - require.NoError(t, err) - body, err = rewriteBusinessSystemPromptCacheKey(c, body, application) - require.NoError(t, err) - wire := gjson.GetBytes(body, "prompt_cache_key").String() + wire := "" for attempt := 0; attempt < 2; attempt++ { req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") require.NoError(t, err) + if attempt == 0 { + wire = req.Header.Get("session-id") + require.NotEmpty(t, wire) + } // 真实 Codex 不发下划线 session_id / conversation_id;缺失连字符会话头时 // 以最终 prompt_cache_key(缓存键改写后的线上值)补齐 session-id。 require.Empty(t, req.Header.Get("session_id")) diff --git a/backend/internal/service/business_system_prompt_composition.go b/backend/internal/service/business_system_prompt_composition.go index 0dbf0f5ba20f..143792041235 100644 --- a/backend/internal/service/business_system_prompt_composition.go +++ b/backend/internal/service/business_system_prompt_composition.go @@ -38,10 +38,10 @@ func NormalizeBusinessSystemPromptCompositionStructure(mode, bundleID, manifestS if mode == "" { mode = BusinessSystemPromptCompositionInline } - if mode != BusinessSystemPromptCompositionInline && mode != BusinessSystemPromptCompositionCodexSkillHybrid { + if mode != BusinessSystemPromptCompositionInline && mode != BusinessSystemPromptCompositionCodexSkillHybrid && mode != extensionv1.PromptContentAnthropicSystemBlocks { return BusinessSystemPromptComposition{}, errors.New("unsupported composition mode") } - if mode == BusinessSystemPromptCompositionInline && (bundleID != "" || manifestSHA256 != "") { + if (mode == BusinessSystemPromptCompositionInline || mode == extensionv1.PromptContentAnthropicSystemBlocks) && (bundleID != "" || manifestSHA256 != "") { return BusinessSystemPromptComposition{}, errors.New("inline composition cannot reference a bundle") } if mode == BusinessSystemPromptCompositionCodexSkillHybrid && bundleID == "" { diff --git a/backend/internal/service/business_system_prompt_echo.go b/backend/internal/service/business_system_prompt_echo.go new file mode 100644 index 000000000000..5f76c776df0a --- /dev/null +++ b/backend/internal/service/business_system_prompt_echo.go @@ -0,0 +1,88 @@ +package service + +import ( + "fmt" + "sort" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// redactPromptRuleCarrierEcho is used only after the exact final carrier hash +// matched. Public source blocks remain visible, including a Claude system +// message containing both public and private rule blocks. +func redactPromptRuleCarrierEcho(envelope []byte, proof promptRulesCarrierUndo, application BusinessSystemPromptApplication) ([]byte, error) { + var placements []extensionv1.PromptRulePlacement + public := false + for _, placement := range application.RulesPlan.Placements { + if placement.Carrier == proof.field { + placements = append(placements, placement) + public = public || placement.PreserveEcho + } + } + if !public { + if proof.restorable { + return restorePromptRules(envelope, []promptRulesCarrierUndo{proof}) + } + // A huge original scalar is deliberately not retained. The exact + // echoed carrier can still be omitted without exposing private text. + return sjson.DeleteBytes(envelope, proof.field) + } + out := envelope + messagePublic := map[int]bool{} + for _, placement := range placements { + if placement.Index != nil && placement.PreserveEcho { + messagePublic[*placement.Index] = true + } + } + removeItems := map[int]bool{} + removeBlocks := map[int][]int{} + for _, placement := range placements { + if placement.PreserveEcho { + continue + } + if placement.Index == nil { + return sjson.DeleteBytes(envelope, proof.field) + } + index := *placement.Index + if proof.field == "messages" && placement.Protocol == "messages" && messagePublic[index] && placement.BlockIndex != nil { + removeBlocks[index] = append(removeBlocks[index], *placement.BlockIndex) + continue + } + count := 1 + if proof.field == "system" && placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + count = len(gjson.ParseBytes(placement.StructuredContent).Array()) + } + for offset := 0; offset < count; offset++ { + removeItems[index+offset] = true + } + } + for message, blocks := range removeBlocks { + sort.Sort(sort.Reverse(sort.IntSlice(blocks))) + for _, block := range blocks { + var err error + out, err = sjson.DeleteBytes(out, fmt.Sprintf("%s.%d.content.%d", proof.field, message, block)) + if err != nil { + return nil, err + } + } + } + indices := make([]int, 0, len(removeItems)) + for index := range removeItems { + indices = append(indices, index) + } + sort.Sort(sort.Reverse(sort.IntSlice(indices))) + arrayPath := proof.field + if proof.field == "systemInstruction" { + arrayPath += ".parts" + } + for _, index := range indices { + var err error + out, err = sjson.DeleteBytes(out, fmt.Sprintf("%s.%d", arrayPath, index)) + if err != nil { + return nil, err + } + } + return out, nil +} diff --git a/backend/internal/service/business_system_prompt_gateway_test.go b/backend/internal/service/business_system_prompt_gateway_test.go index 904574af35fd..36880cef308a 100644 --- a/backend/internal/service/business_system_prompt_gateway_test.go +++ b/backend/internal/service/business_system_prompt_gateway_test.go @@ -32,6 +32,12 @@ func newGatewayBusinessSystemPromptPolicy(t *testing.T, expose, compact bool) *B }} policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) + snapshot, ok := policy.CurrentSnapshot() + require.True(t, ok) + snapshot = promptRulesSnapshotForTest(snapshot) + policy.snapshot.Store(&snapshot) + store.loaded = snapshot + store.detail = BusinessSystemPromptTemplateDetail{Versions: []BusinessSystemPromptVersion{{ID: snapshot.VersionID, TemplateID: snapshot.TemplateID, Body: snapshot.Body, SHA256: snapshot.SHA256, ByteLength: snapshot.ByteLength, CompositionMode: snapshot.CompositionMode}}} return policy } @@ -70,6 +76,14 @@ func newGatewayHybridBusinessSystemPromptPolicyWithBody(t *testing.T, body strin policy := NewBusinessSystemPromptService(store, nil) policy.SetRemoteSkillRegistryService(registry) require.NoError(t, policy.Initialize(context.Background())) + raw, ok := policy.CurrentSnapshot() + require.True(t, ok) + compiled, err := policy.compileBusinessSystemPromptSnapshot(raw) + require.NoError(t, err) + snapshot := promptRulesSnapshotForTest(compiled) + policy.snapshot.Store(&snapshot) + store.loaded = snapshot + store.detail = BusinessSystemPromptTemplateDetail{Versions: []BusinessSystemPromptVersion{{ID: raw.VersionID, TemplateID: raw.TemplateID, Body: raw.Body, SHA256: raw.SHA256, ByteLength: raw.ByteLength, CompositionMode: raw.CompositionMode, BundleID: raw.BundleID}}} return policy } @@ -92,7 +106,7 @@ func TestBusinessSystemPromptHybridUsesSamePairedPublicationForOfficialCodexAndC require.Equal(t, 1, strings.Count(officialInstructions, "`REMOTE_ROOT/README_AI.md`")) require.Equal(t, 1, strings.Count(officialInstructions, "`REMOTE_ROOT/SKILL.md`")) require.Equal(t, embeddedBusinessSystemPrompt, strings.Replace(officialInstructions, RemoteSkillPublicRoot, RemoteSkillMoxinggangRoot, 1)) - require.Equal(t, int64(11), officialApplication.BundleRevision) + require.Equal(t, int64(20), officialApplication.RulesPlan.Placements[0].VersionID) compatible, _ := newBusinessSystemPromptGinContext("/v1/responses", body) compatible.Request.Header.Set("User-Agent", "compatible-client/1.0") @@ -101,14 +115,14 @@ func TestBusinessSystemPromptHybridUsesSamePairedPublicationForOfficialCodexAndC ) require.NoError(t, err) require.Equal(t, officialInstructions, gjson.GetBytes(compatibleBody, "instructions").String()) - require.Equal(t, int64(11), compatibleApplication.BundleRevision) + require.True(t, compatibleApplication.RulesPlan.Placements[0].PreserveEcho) retried, retriedApplication, err := svc.applyBusinessSystemPromptForRequest( - compatible, compatibleBody, account, BusinessSystemPromptProtocolResponses, false, + compatible, body, account, BusinessSystemPromptProtocolResponses, false, ) require.NoError(t, err) require.Equal(t, compatibleBody, retried) - require.Equal(t, compatibleApplication.EffectiveSHA256, retriedApplication.EffectiveSHA256) + require.Equal(t, compatibleApplication.RulesPlan.SHA256, retriedApplication.RulesPlan.SHA256) } func TestBusinessSystemPromptHybridAppliesToFirstClassCindyOpenAIWireAccount(t *testing.T) { @@ -131,8 +145,8 @@ func TestBusinessSystemPromptHybridAppliesToFirstClassCindyOpenAIWireAccount(t * ) require.NoError(t, err) require.True(t, application.Applied) - require.Equal(t, BusinessSystemPromptCompositionCodexSkillHybrid, application.CompositionMode) - require.Equal(t, "client\n\n"+application.ServerInstructions, gjson.GetBytes(updated, "instructions").String()) + require.True(t, application.RulesPlan.Placements[0].PreserveEcho) + require.Equal(t, "client\n\n"+application.RulesPlan.Placements[0].Body, gjson.GetBytes(updated, "instructions").String()) } func TestBusinessSystemPromptHybridPreviewMatchesAppliedBytes(t *testing.T) { @@ -155,10 +169,10 @@ func TestBusinessSystemPromptHybridPreviewMatchesAppliedBytes(t *testing.T) { BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}, ) require.NoError(t, err) - require.Equal(t, preview.Body, application.ServerInstructions) + require.Equal(t, preview.ResolvedRules[0].Body, application.RulesPlan.Placements[0].Body) require.Equal(t, preview.Body, gjson.GetBytes(updated, "instructions").String()) - require.Equal(t, len([]byte(preview.Body)), application.EffectiveByteLength) - require.Equal(t, hashBusinessSystemPromptBundleBytes([]byte(preview.Body)), application.EffectiveSHA256) + require.Equal(t, len([]byte(preview.Body)), len(application.RulesPlan.Placements[0].Body)) + require.Equal(t, hashBusinessSystemPromptBundleBytes([]byte(preview.Body)), application.RulesPlan.Placements[0].SHA256) require.Contains(t, preview.Body, RemoteSkillPublicRoot) }) @@ -265,7 +279,7 @@ func TestBusinessSystemPromptNativeResponsesAppliesForAPIKeyAndOAuth(t *testing. require.Error(t, err) require.Nil(t, result) require.NotNil(t, upstream.lastReq) - require.Equal(t, "client\n\nbusiness-server", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.Equal(t, gjson.GetBytes(body, "instructions").String()+"\n\nbusiness-server", gjson.GetBytes(upstream.lastBody, "instructions").String(), "preserve the customer's instruction bytes") require.Regexp(t, `^[0-9a-f]{64}$`, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, 1, strings.Count(string(upstream.lastBody), "business-server")) }) @@ -429,15 +443,8 @@ func TestBusinessSystemPromptUpstreamErrorIsSanitizedBeforeInspection(t *testing gin.SetMode(gin.TestMode) c, _ := newBusinessSystemPromptGinContext("/v1/responses", nil) svc := &OpenAIGatewayService{businessPromptService: newGatewayBusinessSystemPromptPolicy(t, false, false)} - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ClientInstructions: "client", - ServerInstructions: "business-server", - } - c.Set(businessSystemPromptRequestApplicationKey+":"+BusinessSystemPromptProtocolResponses, businessSystemPromptRequestState{ - application: application, - }) + _, _, err := svc.applyBusinessSystemPromptForRequest(c, []byte(`{"instructions":"client","input":"hello"}`), businessSystemPromptAPIKeyAccount(true), "responses", false) + require.NoError(t, err) resp := &http.Response{ StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader( diff --git a/backend/internal/service/business_system_prompt_offline_integration_test.go b/backend/internal/service/business_system_prompt_offline_integration_test.go index 8796c60969d9..276196895496 100644 --- a/backend/internal/service/business_system_prompt_offline_integration_test.go +++ b/backend/internal/service/business_system_prompt_offline_integration_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "net/http/httptest" "testing" @@ -27,21 +28,33 @@ func TestBusinessSystemPromptHybridCodexUsesFixedBodyAcrossAdapters(t *testing.T ) require.NoError(t, err) require.True(t, application.Applied) - require.Equal(t, BusinessSystemPromptRemoteSkillBundleID, application.BundleID) - require.NotEmpty(t, application.BundleManifestSHA256) - require.Equal(t, int64(11), application.BundleRevision) - require.NotEmpty(t, application.BaseSHA256) + require.Len(t, application.RulesPlan.Placements, 1) + placement := application.RulesPlan.Placements[0] + require.Equal(t, "instructions", placement.Carrier) + require.True(t, placement.PreserveEcho) + require.True(t, application.PreserveInstructionsEcho) require.NotEmpty(t, application.EffectiveSHA256) capture, err := buildRemoteSkillPromptCapture([]byte(embeddedBusinessSystemPrompt)) require.NoError(t, err) expectedPrompt := string(capture.EffectiveBody) - require.Equal(t, capture.RawSHA256, application.BaseSHA256) - require.Equal(t, capture.EffectiveSHA256, application.EffectiveSHA256) - require.NotEqual(t, application.BaseSHA256, application.EffectiveSHA256) - require.Equal(t, expectedPrompt, application.ServerInstructions) - require.Contains(t, application.ServerInstructions, RemoteSkillPublicRoot) - require.NotContains(t, application.ServerInstructions, `C:\Users\Administrator`) - require.Equal(t, application.ServerInstructions, gjson.GetBytes(responsesBody, "instructions").String()) + publication, err := policy.registry.ActivePublication(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(11), publication.Revision) + require.Equal(t, publication.Prompt.ID, publication.Version.PromptVersionID) + require.NotEmpty(t, publication.Version.EffectiveTreeSHA256) + require.Equal(t, capture.RawSHA256, publication.Prompt.RawSHA256) + require.Equal(t, capture.EffectiveSHA256, publication.Prompt.EffectiveSHA256) + require.NotEqual(t, publication.Prompt.RawSHA256, publication.Prompt.EffectiveSHA256) + require.Equal(t, expectedPrompt, publication.EffectivePromptBody) + require.Equal(t, capture.EffectiveSHA256, placement.SHA256) + require.Equal(t, expectedPrompt, placement.Body) + require.Contains(t, placement.Body, RemoteSkillPublicRoot) + require.NotContains(t, placement.Body, `C:\Users\Administrator`) + require.Equal(t, placement.Body, gjson.GetBytes(responsesBody, "instructions").String()) + snapshot, ok := policy.CurrentSnapshot() + require.True(t, ok) + require.Equal(t, snapshot.RulePolicy.Rules[0].TemplateID, placement.TemplateID) + require.Equal(t, snapshot.RulePolicy.Rules[0].VersionID, placement.VersionID) // A transformed fallback body can contain different task words, but the // request-scoped fixed prompt must remain byte-for-byte identical. @@ -53,9 +66,17 @@ func TestBusinessSystemPromptHybridCodexUsesFixedBodyAcrossAdapters(t *testing.T false, ) require.NoError(t, err) - require.Equal(t, application.EffectiveSHA256, fallbackApplication.EffectiveSHA256) - require.Equal(t, expectedPrompt, fallbackApplication.ServerInstructions) - require.True(t, chatBodyHasSystemPrompt(chatBody, application.ServerInstructions)) + require.Len(t, fallbackApplication.RulesPlan.Placements, 1) + fallbackPlacement := fallbackApplication.RulesPlan.Placements[0] + require.Equal(t, placement.SHA256, fallbackPlacement.SHA256) + require.Equal(t, placement.TemplateID, fallbackPlacement.TemplateID) + require.Equal(t, placement.VersionID, fallbackPlacement.VersionID) + require.Equal(t, expectedPrompt, fallbackPlacement.Body) + require.Equal(t, "messages", fallbackPlacement.Carrier) + require.Equal(t, "system", fallbackPlacement.Role) + require.True(t, fallbackPlacement.PreserveEcho) + require.True(t, chatBodyHasSystemPrompt(chatBody, placement.Body)) + require.NotEqual(t, application.EffectiveSHA256, fallbackApplication.EffectiveSHA256, "different final protocol carriers have separate plan identities") cacheKey := deriveBusinessSystemPromptCacheKey(c, "client-key", application) require.Regexp(t, `^[0-9a-f]{64}$`, cacheKey) @@ -76,25 +97,32 @@ func TestBusinessSystemPromptWSHybridCodexTurnsReuseFixedBody(t *testing.T) { account := &Account{Platform: PlatformOpenAI} beginBusinessSystemPromptRequestTurn(c) - _, first, err := gateway.applyBusinessSystemPromptForRequest(c, + firstBody, first, err := gateway.applyBusinessSystemPromptForRequest(c, []byte(`{"type":"response.create","input":[{"role":"user","content":"audit OAuth API"}]}`), account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) - require.Equal(t, expectedPrompt, first.ServerInstructions) + require.Len(t, first.RulesPlan.Placements, 1) + require.Equal(t, expectedPrompt, first.RulesPlan.Placements[0].Body) + require.Equal(t, expectedPrompt, gjson.GetBytes(firstBody, "instructions").String()) beginBusinessSystemPromptRequestTurn(c) - _, continued, err := gateway.applyBusinessSystemPromptForRequest(c, + continuedBody, continued, err := gateway.applyBusinessSystemPromptForRequest(c, []byte(`{"type":"response.create","previous_response_id":"resp_route_1","input":[]}`), account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) require.Equal(t, first.EffectiveSHA256, continued.EffectiveSHA256) - require.Equal(t, expectedPrompt, continued.ServerInstructions) + require.Equal(t, expectedPrompt, continued.RulesPlan.Placements[0].Body) + require.Equal(t, expectedPrompt, gjson.GetBytes(continuedBody, "instructions").String()) + require.Equal(t, "resp_route_1", gjson.GetBytes(continuedBody, "previous_response_id").String()) + require.Empty(t, gjson.GetBytes(continuedBody, "input").Array()) beginBusinessSystemPromptRequestTurn(c) - _, next, err := gateway.applyBusinessSystemPromptForRequest(c, + nextBody, next, err := gateway.applyBusinessSystemPromptForRequest(c, []byte(`{"type":"response.create","previous_response_id":"resp_route_1","input":[{"role":"user","content":"analyze this malware"}]}`), account, BusinessSystemPromptProtocolResponses, false) require.NoError(t, err) require.Equal(t, first.EffectiveSHA256, next.EffectiveSHA256) - require.Equal(t, expectedPrompt, next.ServerInstructions) + require.Equal(t, expectedPrompt, next.RulesPlan.Placements[0].Body) + require.Equal(t, expectedPrompt, gjson.GetBytes(nextBody, "instructions").String()) + require.Equal(t, "analyze this malware", gjson.GetBytes(nextBody, "input.0.content").String()) } diff --git a/backend/internal/service/business_system_prompt_policy.go b/backend/internal/service/business_system_prompt_policy.go index cc8ab5359121..f48c3d016f65 100644 --- a/backend/internal/service/business_system_prompt_policy.go +++ b/backend/internal/service/business_system_prompt_policy.go @@ -7,9 +7,9 @@ import ( "errors" "fmt" "strings" - "time" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -20,6 +20,8 @@ const ( BusinessSystemPromptProtocolResponses = "responses" BusinessSystemPromptProtocolChat = "chat" + BusinessSystemPromptProtocolMessages = "messages" + BusinessSystemPromptProtocolGemini = "gemini" BusinessSystemPromptCarrierInstructions = "instructions" BusinessSystemPromptCarrierSystemMessage = "system_message" @@ -68,147 +70,52 @@ func ApplyBusinessSystemPromptToJSON(body []byte, snapshot BusinessSystemPromptS } func ApplyBusinessSystemPromptToJSONContext(ctx context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) ([]byte, BusinessSystemPromptApplication, error) { - return applyBusinessSystemPromptWithInvoker(ctx, body, snapshot, target, promptPlanInvoke) -} - -func applyBusinessSystemPromptWithInvoker(parent context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, invoke func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) ([]byte, BusinessSystemPromptApplication, error) { - application, err := planBusinessSystemPromptWithInvoker(parent, body, snapshot, target, invoke) + application, err := planBusinessSystemPrompt(ctx, snapshot, target) if err != nil { return nil, application, err } return applyBusinessSystemPromptApplication(body, application) } -// Planning rechecks the live execution scope, including on request-cache hits. -// Only bounded policy material and a presence bit cross the RPC boundary. -func planBusinessSystemPromptWithInvoker(parent context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, invoke func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) (BusinessSystemPromptApplication, error) { - request := extensionv1.PromptPlanRequest{Snapshot: snapshot, Target: target, HasInstructions: gjson.GetBytes(body, "instructions").Exists(), BaseSHA256: snapshot.BaseSHA256, EffectiveSHA256: snapshot.EffectiveSHA256, EffectiveByteLength: snapshot.EffectiveByteLength} - raw, err := json.Marshal(request) - if err != nil { - return BusinessSystemPromptApplication{}, err - } - ctx, cancel := context.WithTimeout(parent, time.Second) - defer cancel() - accountType := target.AccountType - if accountType == "" { - accountType = "*" - } - result, err := invoke(ctx, target.Platform, accountType, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "prompt.plan", AccountID: target.AccountID, Payload: raw}) - if errors.Is(err, ErrExtensionOperationDisabled) { - if snapshot.RulePolicy != nil { - plan := &extensionv1.PromptRulesPlan{Placements: []extensionv1.PromptRulePlacement{}, Skipped: []extensionv1.PromptRuleDecision{}} - for _, rule := range snapshot.RulePolicy.Rules { - plan.Skipped = append(plan.Skipped, extensionv1.PromptRuleDecision{RuleID: rule.ID, Reason: "plugin_scope"}) - } - return BusinessSystemPromptApplication{Revision: snapshot.Revision, RulesPlan: plan}, nil - } - return BusinessSystemPromptApplication{}, nil - } - if result.Code == "prompt_delivery_unsupported" { - return BusinessSystemPromptApplication{}, ErrPromptDeliveryUnsupported - } - if err != nil || result.Code != "" { - if !snapshot.Enabled { - return BusinessSystemPromptApplication{}, nil - } - return BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - var application BusinessSystemPromptApplication - if json.Unmarshal(result.Payload, &application) != nil { - return application, ErrBusinessSystemPromptUnavailable +func planBusinessSystemPrompt(ctx context.Context, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) (BusinessSystemPromptApplication, error) { + if err := ctx.Err(); err != nil { + return BusinessSystemPromptApplication{}, fmt.Errorf("%w: %w", ErrBusinessSystemPromptUnavailable, err) } - if snapshot.RulePolicy != nil && application.RulesPlan == nil { - if !snapshot.Enabled { - return BusinessSystemPromptApplication{}, nil + application, err := promptpolicy.PlanRules(snapshot, target) + if err != nil { + if errors.Is(err, promptpolicy.ErrPromptDeliveryUnsupported) { + return application, fmt.Errorf("%w: %v", ErrPromptDeliveryUnsupported, err) } - return BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable + return application, fmt.Errorf("%w: %v", ErrBusinessSystemPromptUnavailable, err) } return application, nil } -func applyBusinessSystemPromptApplication(body []byte, application BusinessSystemPromptApplication) ([]byte, BusinessSystemPromptApplication, error) { - if application.RulesPlan != nil { - return applyPromptRules(body, application) - } - if !application.Applied { - return body, application, nil - } - if !json.Valid(body) { - return nil, application, fmt.Errorf("apply business system prompt: invalid JSON") - } - switch application.Carrier { - case BusinessSystemPromptCarrierInstructions: - return applyBusinessSystemPromptInstructions(body, application.ServerInstructions, application) - case BusinessSystemPromptCarrierSystemMessage: - return applyBusinessSystemPromptChatMessages(body, application.ServerInstructions, application) - default: - return nil, application, ErrBusinessSystemPromptUnavailable - } +// PlanBusinessSystemPrompt selects rules and their final protocol carriers +// without copying or modifying customer history. Anchor validation happens in +// the shared applier when the final outgoing sequence is available. +func PlanBusinessSystemPrompt(ctx context.Context, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) (BusinessSystemPromptApplication, error) { + return planBusinessSystemPrompt(ctx, snapshot, target) } -func applyBusinessSystemPromptInstructions( - body []byte, - server string, - application BusinessSystemPromptApplication, -) ([]byte, BusinessSystemPromptApplication, error) { - instructions := gjson.GetBytes(body, "instructions") - if instructions.Exists() && instructions.Type != gjson.String { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("apply business system prompt: instructions must be a string") - } - application.Carrier = BusinessSystemPromptCarrierInstructions - application.ClientInstructions = strings.TrimSpace(instructions.String()) - merged := MergeBusinessSystemPromptInstructions(application.ClientInstructions, server) - updated, err := sjson.SetBytes(body, "instructions", merged) - if err != nil { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("apply business system prompt instructions: %w", err) - } - return updated, application, nil +// Compatibility wrappers for internal callers while request lifecycle adapters +// migrate. Planning is local and never invokes the legacy prompt.plan RPC. +func applyBusinessSystemPromptWithInvoker(ctx context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, _ func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) ([]byte, BusinessSystemPromptApplication, error) { + return ApplyBusinessSystemPromptToJSONContext(ctx, body, snapshot, target) } -func applyBusinessSystemPromptChatMessages( - body []byte, - server string, - application BusinessSystemPromptApplication, -) ([]byte, BusinessSystemPromptApplication, error) { - var envelope struct { - Messages []json.RawMessage `json:"messages"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("parse chat messages: %w", err) - } - if envelope.Messages == nil { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("apply business system prompt: messages must be an array") - } +func planBusinessSystemPromptWithInvoker(ctx context.Context, _ []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, _ func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) (BusinessSystemPromptApplication, error) { + return planBusinessSystemPrompt(ctx, snapshot, target) +} - insertAt := 0 - for insertAt < len(envelope.Messages) { - role := strings.ToLower(strings.TrimSpace(gjson.GetBytes(envelope.Messages[insertAt], "role").String())) - if role != "system" && role != "developer" { - break - } - insertAt++ - } - serverMessage, err := json.Marshal(map[string]string{ - "role": "system", - "content": strings.TrimSpace(server), - }) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - messages := make([]json.RawMessage, 0, len(envelope.Messages)+1) - messages = append(messages, envelope.Messages[:insertAt]...) - messages = append(messages, serverMessage) - messages = append(messages, envelope.Messages[insertAt:]...) - rawMessages, err := json.Marshal(messages) - if err != nil { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("marshal chat messages: %w", err) +func applyBusinessSystemPromptApplication(body []byte, application BusinessSystemPromptApplication) ([]byte, BusinessSystemPromptApplication, error) { + if application.RulesPlan != nil { + return applyPromptRules(body, application) } - updated, err := sjson.SetRawBytes(body, "messages", rawMessages) - if err != nil { - return nil, BusinessSystemPromptApplication{}, fmt.Errorf("apply business system prompt chat messages: %w", err) + if !application.Applied { + return body, application, nil } - application.Carrier = BusinessSystemPromptCarrierSystemMessage - return updated, application, nil + return nil, application, ErrBusinessSystemPromptUnavailable } func RewriteBusinessSystemPromptResponseJSON( diff --git a/backend/internal/service/business_system_prompt_reasoning_source.go b/backend/internal/service/business_system_prompt_reasoning_source.go new file mode 100644 index 000000000000..99b6b2187531 --- /dev/null +++ b/backend/internal/service/business_system_prompt_reasoning_source.go @@ -0,0 +1,88 @@ +package service + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +func prepareBusinessPromptReasoningRequest(c *gin.Context, recovery *openAIReasoningRecoveryState, req *http.Request, clean []byte, proxyURL string) (*http.Request, []byte, []byte, error) { + wire, err := businessPromptWireRequestBody(req, clean) + if err != nil { + return nil, nil, nil, err + } + prepared, final, err := recovery.PrepareRequest(req, wire, proxyURL) + if err != nil { + return nil, nil, nil, err + } + clean, err = projectReasoningCipherEdits(clean, wire, final) + if err != nil { + return nil, nil, nil, err + } + refreshBusinessPromptSendEvidence(c, clean, final, BusinessSystemPromptProtocolResponses) + return prepared, clean, final, nil +} + +func businessPromptWireRequestBody(req *http.Request, fallback []byte) ([]byte, error) { + if req.GetBody != nil { + reader, err := req.GetBody() + if err != nil { + return nil, err + } + wire, err := io.ReadAll(reader) + _ = reader.Close() + if err != nil { + return nil, err + } + return wire, nil + } + return fallback, nil +} + +// projectReasoningCipherEdits repeats a recovery edit on the clean request. +// It never removes prompt messages from a wire body. Recovery may only delete +// encrypted_content from reasoning items, whose relative order is unchanged by +// prompt placement. Any other difference is rejected instead of becoming the +// next retry's source. +func projectReasoningCipherEdits(clean, before, after []byte) ([]byte, error) { + if bytes.Equal(before, after) { + return clean, nil + } + wireItems, cleanItems := openAIReasoningCipherItems(before), openAIReasoningCipherItems(clean) + if len(wireItems) != len(cleanItems) { + return nil, fmt.Errorf("%w: reasoning source changed", ErrBusinessSystemPromptUnavailable) + } + wireIndices, cleanIndices := []int{}, []int{} + for index, item := range wireItems { + if item.hash != cleanItems[index].hash { + return nil, fmt.Errorf("%w: reasoning source changed", ErrBusinessSystemPromptUnavailable) + } + value := gjson.GetBytes(after, fmt.Sprintf("input.%d.encrypted_content", item.index)) + if !value.Exists() { + wireIndices = append(wireIndices, item.index) + cleanIndices = append(cleanIndices, cleanItems[index].index) + } + } + expected, err := stripOpenAIReasoningCipherIndices(before, wireIndices) + if err != nil || !bytes.Equal(expected, after) { + return nil, fmt.Errorf("%w: unsupported reasoning retry edit", ErrBusinessSystemPromptUnavailable) + } + return stripOpenAIReasoningCipherIndices(clean, cleanIndices) +} + +// refreshBusinessPromptSendEvidence keeps response-echo and integrity proofs +// aligned when the independent reasoning cache removed ciphertext after the +// request builder. The prompt itself is unchanged and is not reapplied. +func refreshBusinessPromptSendEvidence(c *gin.Context, clean, wire []byte, protocol string) { + key := businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, protocol) + value, exists := businessSystemPromptRequestGet(c, key) + state, ok := value.(businessSystemPromptRequestState) + if !exists || !ok { + return + } + businessSystemPromptRequestSet(c, key, cacheBusinessSystemPromptState(clean, wire, state.snapshot, state.target, state.application)) +} diff --git a/backend/internal/service/business_system_prompt_replay_scope.go b/backend/internal/service/business_system_prompt_replay_scope.go new file mode 100644 index 000000000000..e5c79538efca --- /dev/null +++ b/backend/internal/service/business_system_prompt_replay_scope.go @@ -0,0 +1,34 @@ +package service + +import ( + "github.com/gin-gonic/gin" +) + +// Replay now operates on the clean converted conversation, before placement. +// Bind its cache to the frozen rule selection so a prompt change cannot reuse +// encrypted reasoning produced under another instruction contract. +func (s *OpenAIGatewayService) businessPromptReplayScope(c *gin.Context, account *Account, body []byte, scope OpenAIReasoningCacheScope) (OpenAIReasoningCacheScope, error) { + if s == nil || s.businessPromptService == nil || account == nil { + return scope, nil + } + service := s.businessPromptService + snapshot, err := service.snapshotForRequest(c) + if err != nil { + return scope, err + } + if !snapshot.Enabled || snapshot.RulePolicy == nil || len(snapshot.RulePolicy.Rules) == 0 || (isOpenAIResponsesCompactPath(c) && !snapshot.CompactEnabled) { + return scope, nil + } + target, err := service.freshPromptBinding(c, account, promptSendTarget(c, account, body, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c), "")) + if err != nil { + return scope, err + } + application, err := planBusinessSystemPrompt(promptPolicyRequestContext(c), snapshot, target) + if err != nil { + return scope, err + } + if namespace := businessSystemPromptCacheNamespace(application); namespace != "" { + scope.ScopeHash = openAIReasoningDigest([]byte(scope.ScopeHash + namespace)) + } + return scope, nil +} diff --git a/backend/internal/service/business_system_prompt_retry_source.go b/backend/internal/service/business_system_prompt_retry_source.go new file mode 100644 index 000000000000..e521f3751eae --- /dev/null +++ b/backend/internal/service/business_system_prompt_retry_source.go @@ -0,0 +1,72 @@ +package service + +import ( + "regexp" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var promptRetryInputIndex = regexp.MustCompile(`input\[(\d+)\]`) + +// A provider's error indices describe the sent array. Translate those indices +// to the preserved customer array before running an existing bounded repair. +// A rejection of an inserted prompt is never repaired by editing a customer +// message at the same numeric index. +func normalizeBusinessPromptRejectedFieldRetryBody(c *gin.Context, status int, clean, response []byte) ([]byte, string, bool, error) { + value, exists := businessSystemPromptRequestGet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolResponses)) + state, ok := value.(businessSystemPromptRequestState) + if !exists || !ok || !state.application.Applied { + return normalizeOpenAIResponsesRejectedFieldRetryBody(status, clean, response) + } + var indices []int + for _, proof := range state.rulesUndo { + if proof.field == "input" { + if !proof.valid { + return nil, "", false, ErrBusinessSystemPromptUnavailable + } + indices = proof.indices + } + } + if len(indices) == 0 { + return normalizeOpenAIResponsesRejectedFieldRetryBody(status, clean, response) + } + out := response + owned := false + for _, field := range []string{"error.param", "error.message", "param", "message"} { + text := gjson.GetBytes(out, field) + if text.Type != gjson.String { + continue + } + mapped := promptRetryInputIndex.ReplaceAllStringFunc(text.String(), func(match string) string { + parts := promptRetryInputIndex.FindStringSubmatch(match) + index, err := strconv.Atoi(parts[1]) + if err != nil { + owned = true + return match + } + before := 0 + for _, insertion := range indices { + if insertion == index { + owned = true + return match + } + if insertion < index { + before++ + } + } + return "input[" + strconv.Itoa(index-before) + "]" + }) + if owned { + return nil, "", false, nil + } + var err error + out, err = sjson.SetBytes(out, field, mapped) + if err != nil { + return nil, "", false, err + } + } + return normalizeOpenAIResponsesRejectedFieldRetryBody(status, clean, out) +} diff --git a/backend/internal/service/business_system_prompt_rules_apply.go b/backend/internal/service/business_system_prompt_rules_apply.go index 0a0aba89f78e..7c9f56833361 100644 --- a/backend/internal/service/business_system_prompt_rules_apply.go +++ b/backend/internal/service/business_system_prompt_rules_apply.go @@ -8,9 +8,9 @@ import ( "fmt" "net/http" "sort" - "strings" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -37,114 +37,54 @@ func applyPromptRules(body []byte, application BusinessSystemPromptApplication) if application.RulesPlan == nil || !application.Applied { return body, application, nil } - if !json.Valid(body) { + if !json.Valid(body) || !gjson.ParseBytes(body).IsObject() { return nil, application, ErrBusinessSystemPromptInvalid } - groups := map[string][]extensionv1.PromptRulePlacement{} - for _, placement := range application.RulesPlan.Placements { - groups[placement.Carrier] = append(groups[placement.Carrier], placement) - } + originalPlan := application.RulesPlan + copied := *originalPlan + copied.Placements = append([]extensionv1.PromptRulePlacement{}, originalPlan.Placements...) + copied.Skipped = append([]extensionv1.PromptRuleDecision{}, originalPlan.Skipped...) + application.RulesPlan = &copied + groups := map[string][]int{} + for index, placement := range copied.Placements { + switch placement.Carrier { + case "instructions", "input", "messages", "system", "systemInstruction": + default: + return nil, application, ErrPromptDeliveryUnsupported + } + groups[placement.Carrier] = append(groups[placement.Carrier], index) + } + applied := make([]bool, len(copied.Placements)) out := body - for _, field := range []string{"instructions", "input", "messages"} { + for _, field := range []string{"instructions", "system", "systemInstruction", "input", "messages"} { placements := groups[field] if len(placements) == 0 { continue } - value := gjson.GetBytes(out, field) - if field == "instructions" { - if value.Exists() && value.Type != gjson.String { - return nil, application, fmt.Errorf("%w: instructions must be a string", ErrBusinessSystemPromptInvalid) - } - application.OriginalInstructionsExists = value.Exists() - application.ClientInstructions = value.String() - before, after := []string{}, []string{} - publicBefore, publicAfter := []string{}, []string{} - for _, placement := range placements { - switch placement.Position { - case extensionv1.PromptPositionControlPrepend: - before = append(before, placement.Body) - if placement.PreserveEcho { - publicBefore = append(publicBefore, placement.Body) - } - case extensionv1.PromptPositionControlAppend: - after = append(after, placement.Body) - if placement.PreserveEcho { - publicAfter = append(publicAfter, placement.Body) - } - default: - return nil, application, ErrPromptDeliveryUnsupported - } - } - parts := append(before, strings.TrimSpace(value.String())) - parts = append(parts, after...) - parts = nonEmptyPromptParts(parts) - application.FinalInstructions = strings.Join(parts, "\n\n") - application.PublicInstructions, application.PublicInstructionsExists = value.String(), value.Exists() - if len(publicBefore)+len(publicAfter) > 0 { - publicParts := append(publicBefore, strings.TrimSpace(value.String())) - publicParts = append(publicParts, publicAfter...) - application.PublicInstructions, application.PublicInstructionsExists = strings.Join(nonEmptyPromptParts(publicParts), "\n\n"), true - } - var err error - out, err = sjson.SetBytes(out, field, application.FinalInstructions) - if err != nil { - return nil, application, err - } - continue - } - items := []json.RawMessage{} - if field == "input" && value.Type == gjson.String { - message, _ := json.Marshal(map[string]any{"role": "user", "content": value.String()}) - items = append(items, message) - } else if value.Exists() { - if !value.IsArray() || json.Unmarshal([]byte(value.Raw), &items) != nil { - return nil, application, fmt.Errorf("%w: %s must be a message array", ErrBusinessSystemPromptInvalid, field) - } - } else if field == "messages" { - return nil, application, fmt.Errorf("%w: messages must be an array", ErrBusinessSystemPromptInvalid) - } - controlEnd := 0 - for controlEnd < len(items) { - role := gjson.GetBytes(items[controlEnd], "role").String() - if role != "system" && role != "developer" { - break - } - controlEnd++ - } - insertions := map[int][]json.RawMessage{} - for _, placement := range placements { - if placement.Role != "system" && placement.Role != "developer" { - return nil, application, ErrPromptDeliveryUnsupported - } - index := 0 - switch placement.Position { - case extensionv1.PromptPositionControlPrepend, extensionv1.PromptPositionConversationHead: - case extensionv1.PromptPositionControlAppend: - index = controlEnd - case extensionv1.PromptPositionConversationTail: - index = len(items) - default: - return nil, application, ErrPromptDeliveryUnsupported - } - message, _ := json.Marshal(map[string]string{"role": placement.Role, "content": placement.Body}) - insertions[index] = append(insertions[index], message) - } - combined := make([]json.RawMessage, 0, len(items)+len(placements)) - for index := 0; index <= len(items); index++ { - combined = append(combined, insertions[index]...) - if index < len(items) { - combined = append(combined, items[index]) - } + var err error + switch field { + case "instructions": + out, err = applyPromptInstructions(out, &application, placements, applied) + case "system", "systemInstruction": + out, err = applyPromptSystemBlocks(out, field, &application, placements, applied) + default: + out, err = applyPromptMessages(out, field, &application, placements, applied) } - raw, err := json.Marshal(combined) if err != nil { return nil, application, err } - out, err = sjson.SetRawBytes(out, field, raw) - if err != nil { - return nil, application, err + } + retained := make([]extensionv1.PromptRulePlacement, 0, len(copied.Placements)) + for index, placement := range copied.Placements { + if applied[index] { + retained = append(retained, placement) } } + application.RulesPlan.Placements = retained + application = promptpolicy.FinishRulesPlan(application) + if !application.Applied { + return body, application, nil + } return out, application, nil } @@ -199,7 +139,7 @@ func promptRulesUndo(input, output []byte, application BusinessSystemPromptAppli if !application.Applied || application.RulesPlan == nil { return proofs } - for _, field := range []string{"instructions", "input", "messages"} { + for _, field := range []string{"instructions", "input", "messages", "system", "systemInstruction"} { placements := []extensionv1.PromptRulePlacement{} for _, placement := range application.RulesPlan.Placements { if placement.Carrier == field { @@ -219,33 +159,25 @@ func promptRulesUndo(input, output []byte, application BusinessSystemPromptAppli proof.scalar = append([]byte(nil), beforeRaw...) } } else { - original := before.Array() - controlEnd := 0 - for controlEnd < len(original) { - role := original[controlEnd].Get("role").String() - if role != "system" && role != "developer" { - break - } - controlEnd++ - } - counts := map[int]int{} + seen := map[int]bool{} for _, placement := range placements { - index := 0 - switch placement.Position { - case extensionv1.PromptPositionControlAppend: - index = controlEnd - case extensionv1.PromptPositionConversationTail: - index = len(original) + if placement.Index == nil { + proof.valid = false + continue } - counts[index]++ - } - added := 0 - for index := 0; index <= len(original); index++ { - for n := 0; n < counts[index]; n++ { - proof.indices = append(proof.indices, index+added) - added++ + count := 1 + if field == "system" && placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + count = len(gjson.ParseBytes(placement.StructuredContent).Array()) + } + for offset := 0; offset < count; offset++ { + index := *placement.Index + offset + if !seen[index] { + proof.indices = append(proof.indices, index) + seen[index] = true + } } } + sort.Ints(proof.indices) proof.valid = proof.valid && after.IsArray() } proofs = append(proofs, proof) @@ -292,20 +224,6 @@ func restorePromptRules(body []byte, proofs []promptRulesCarrierUndo) ([]byte, e return out, nil } -func sameBusinessPromptPlan(left, right BusinessSystemPromptApplication) bool { - left.ClientInstructions, right.ClientInstructions = "", "" - left.FinalInstructions, right.FinalInstructions = "", "" - left.PublicInstructions, right.PublicInstructions = "", "" - left.PublicInstructionsExists, right.PublicInstructionsExists = false, false - left.OriginalInstructionsExists, right.OriginalInstructionsExists = false, false - leftPlan, rightPlan := left.RulesPlan, right.RulesPlan - left.RulesPlan, right.RulesPlan = nil, nil - if left != right { - return false - } - return (leftPlan == nil && rightPlan == nil) || (leftPlan != nil && rightPlan != nil && leftPlan.SHA256 == rightPlan.SHA256) -} - func validateBusinessSystemPromptFinal(c *gin.Context, body []byte, protocol string) error { value, exists := businessSystemPromptRequestGet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, protocol)) if !exists { @@ -372,12 +290,15 @@ func promptRulesUseCarrier(application BusinessSystemPromptApplication, field st return false } +var promptRuleEchoPrefixes = []string{"", "response.", "error.", "error.response.", "error.body.", "request.", "generateContentRequest.", "message.", "error.request.", "error.body.request.", "response.request."} + func rewritePromptRulesResponse(body []byte, application BusinessSystemPromptApplication, expose bool) ([]byte, error) { if expose || application.PreserveInstructionsEcho || !application.Applied || application.FinalInstructions == "" || !json.Valid(body) { return body, nil } out := body - for _, path := range []string{"instructions", "response.instructions", "error.instructions", "error.response.instructions"} { + for _, prefix := range promptRuleEchoPrefixes { + path := prefix + "instructions" value := gjson.GetBytes(out, path) if value.Type != gjson.String || value.String() != application.FinalInstructions { continue @@ -411,7 +332,7 @@ func rewritePromptRulesStructuredEcho(c *gin.Context, body []byte, protocol stri if proof.field == "instructions" { continue } - for _, prefix := range []string{"", "response.", "error.", "error.response.", "error.body."} { + for _, prefix := range promptRuleEchoPrefixes { path := prefix + proof.field echo := gjson.GetBytes(out, path) if !echo.Exists() || promptRuleFieldHash([]byte(echo.Raw)) != proof.afterHash { @@ -421,7 +342,7 @@ func rewritePromptRulesStructuredEcho(c *gin.Context, body []byte, protocol stri if err != nil { continue } - clean, err := restorePromptRules(envelope, []promptRulesCarrierUndo{proof}) + clean, err := redactPromptRuleCarrierEcho(envelope, proof, state.application) if err != nil { continue } @@ -496,15 +417,26 @@ func (s *OpenAIGatewayService) finalizeBusinessPromptForSend(c *gin.Context, acc if err != nil { return nil, err } + if protocol == BusinessSystemPromptProtocolResponses { + var changed bool + updated, changed, err = normalizeCindyManagedPromptCacheKey(updated, c, account) + if err != nil { + return nil, err + } + if changed { + observeCindyManagedPromptCacheNormalization(c, true) + } + } if err := validateBusinessSystemPromptFinal(c, updated, protocol); err != nil { return nil, err } - observePromptRulesFinal(c, account, protocol, application) return updated, nil } func writePromptDeliveryError(c *gin.Context, err error) { if errors.Is(err, ErrPromptDeliveryUnsupported) && c != nil && c.Writer != nil && !c.Writer.Written() { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"type": "invalid_request_error", "code": "prompt_delivery_unsupported", "message": "The configured prompt role or position is not supported by this destination"}}) + } else if errors.Is(err, ErrBusinessSystemPromptUnavailable) && c != nil && c.Writer != nil && !c.Writer.Written() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "system_prompt_unavailable", "code": "system_prompt_unavailable", "message": "The configured prompt is temporarily unavailable"}}) } } diff --git a/backend/internal/service/business_system_prompt_rules_gateway_test.go b/backend/internal/service/business_system_prompt_rules_gateway_test.go index 57649fbec08f..768189766264 100644 --- a/backend/internal/service/business_system_prompt_rules_gateway_test.go +++ b/backend/internal/service/business_system_prompt_rules_gateway_test.go @@ -16,9 +16,12 @@ import ( ) func newPromptRulesGatewayPolicy(delivery, position string) *BusinessSystemPromptService { - rule := extensionv1.PromptRule{ID: "site", Name: "Site rule", Enabled: true, TemplateID: 1, VersionID: 2, Order: 100, Delivery: delivery, Position: position, ModelMatch: "upstream", Models: []string{}} + if delivery == "native_control" { + delivery = "auto" + } + rule := extensionv1.PromptRule{ID: "site", Name: "Site rule", Enabled: true, TemplateID: 1, VersionID: 2, Order: 100, Role: delivery, Platforms: []string{PlatformOpenAI, PlatformCindy}, Position: position, ModelMatch: "upstream", Models: []string{}} hash, _, _ := extensionv1.ValidateTextDocument("site-rule-content", 100) - snapshot := BusinessSystemPromptSnapshot{Revision: 8, Enabled: true, RulePolicy: &extensionv1.PromptRulePolicy{Version: 1, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"site"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site-rule-content", SHA256: hash}}} + snapshot := BusinessSystemPromptSnapshot{Revision: 8, Enabled: true, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"site"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site-rule-content", SHA256: hash}}} service := NewBusinessSystemPromptService(nil, nil) service.snapshot.Store(&snapshot) return service @@ -85,7 +88,7 @@ func TestPromptRulesFinalHTTPPaths(t *testing.T) { } } -func TestPromptRulesRetryScopeRestoresOnlyOwnedInsertions(t *testing.T) { +func TestPromptRulesRetryScopeUsesCleanSource(t *testing.T) { policy := newPromptRulesGatewayPolicy("system", "control_prepend") gateway := &OpenAIGatewayService{businessPromptService: policy} account := businessSystemPromptAPIKeyAccount(true) @@ -93,12 +96,12 @@ func TestPromptRulesRetryScopeRestoresOnlyOwnedInsertions(t *testing.T) { c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) first, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) - retry, err := gateway.finalizeBusinessPromptForSend(c, account, first, "responses", false) + retry, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) require.Equal(t, first, retry) require.Equal(t, 2, strings.Count(string(retry), "site-rule-content"), "the identical customer message must remain") account.Extra[PromptAccountBindingExtraKey] = extensionv1.PromptAccountBinding{Mode: "off"} - clean, err := gateway.finalizeBusinessPromptForSend(c, account, retry, "responses", false) + clean, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) require.JSONEq(t, string(input), string(clean)) account.Extra[PromptAccountBindingExtraKey] = extensionv1.PromptAccountBinding{Mode: "inherit"} @@ -110,9 +113,7 @@ func TestPromptRulesRetryScopeRestoresOnlyOwnedInsertions(t *testing.T) { require.NoError(t, err) require.Equal(t, 2, strings.Count(string(reapplied), "site-rule-content")) require.Equal(t, "resp_keep", gjson.GetBytes(reapplied, "previous_response_id").String()) - convertedInput, err := restoreBusinessSystemPromptBeforeConversion(c, reapplied, "responses") - require.NoError(t, err) - require.JSONEq(t, gjson.GetBytes(input, "input").Raw, gjson.GetBytes(convertedInput, "input").Raw) + require.Equal(t, 1, strings.Count(string(input), "site-rule-content"), "the clean source retains only its customer-owned message") } func TestPromptRulesPassthroughFirstAndSecondTurnWire(t *testing.T) { @@ -191,9 +192,8 @@ func TestPromptRulesNativeWSReplayKeepsCleanAccumulatorAndFrozenPolicy(t *testin first := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":"first"}],"previous_response_id":"resp_anchor"}`) c, _ := newBusinessSystemPromptGinContext("/v1/responses", first) beginBusinessSystemPromptRequestTurn(c) - parsed, plan, err := gateway.prepareBusinessPromptWSIngress(c, first, account, "responses", false) + parsed, _, err := gateway.prepareBusinessPromptWSIngress(c, first, account, "responses", false) require.NoError(t, err) - require.True(t, plan.Applied) require.Equal(t, first, parsed, "the accumulator must never receive the site's messages") wire, err := gateway.finalizeBusinessPromptWSIngress(c, account, parsed) require.NoError(t, err) @@ -288,21 +288,20 @@ func TestPromptRulesAccountBindingsCASAndShadowIndependence(t *testing.T) { require.NotContains(t, string(encoded), "site-rule-content") } -func TestPromptRulesExecutionScopeRevocationRestoresWireAndCacheKey(t *testing.T) { - gateway, invoker, _ := newAccountScopedPromptGateway(t, 100) - gateway.businessPromptService = newPromptRulesGatewayPolicy("developer", "conversation_tail") +func TestPromptRulesAccountBindingRevocationUsesCleanSource(t *testing.T) { + gateway := &OpenAIGatewayService{businessPromptService: newPromptRulesGatewayPolicy("developer", "conversation_tail")} account := businessSystemPromptAPIKeyAccount(true) input := []byte(`{"model":"gpt-5.4","prompt_cache_key":"original","input":[{"role":"user","content":"hello"}]}`) c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) wire, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) require.Contains(t, string(wire), "site-rule-content") - invoker.rollout = 0 - clean, err := gateway.finalizeBusinessPromptForSend(c, account, wire, "responses", false) + account.Extra[PromptAccountBindingExtraKey] = extensionv1.PromptAccountBinding{Mode: "off"} + clean, err := gateway.finalizeBusinessPromptForSend(c, account, input, "responses", false) require.NoError(t, err) require.JSONEq(t, string(input), string(clean)) application, ok := businessSystemPromptApplicationFromRequest(c, "responses") require.True(t, ok) require.False(t, application.Applied) - require.Equal(t, "plugin_scope", application.RulesPlan.Skipped[0].Reason) + require.Equal(t, "account_scope", application.RulesPlan.Skipped[0].Reason) } diff --git a/backend/internal/service/business_system_prompt_rules_insert.go b/backend/internal/service/business_system_prompt_rules_insert.go new file mode 100644 index 000000000000..712a653d56a8 --- /dev/null +++ b/backend/internal/service/business_system_prompt_rules_insert.go @@ -0,0 +1,382 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func applyPromptInstructions(body []byte, application *BusinessSystemPromptApplication, indices []int, applied []bool) ([]byte, error) { + value, _, unique := uniquePromptField(body, "instructions") + if !unique || (value.Exists() && value.Type != gjson.String) { + return nil, fmt.Errorf("%w: instructions must be a unique string", ErrBusinessSystemPromptInvalid) + } + application.OriginalInstructionsExists, application.ClientInstructions = value.Exists(), value.String() + before, after, publicBefore, publicAfter := []string{}, []string{}, []string{}, []string{} + for _, index := range indices { + placement := &application.RulesPlan.Placements[index] + switch placement.Position { + case extensionv1.PromptPositionControlPrepend: + before = append(before, placement.Body) + if placement.PreserveEcho { + publicBefore = append(publicBefore, placement.Body) + } + case extensionv1.PromptPositionControlAppend: + after = append(after, placement.Body) + if placement.PreserveEcho { + publicAfter = append(publicAfter, placement.Body) + } + default: + return nil, ErrPromptDeliveryUnsupported + } + applied[index] = true + } + parts := append(before, value.String()) + parts = append(parts, after...) + application.FinalInstructions = strings.Join(nonEmptyPromptParts(parts), "\n\n") + application.PublicInstructions, application.PublicInstructionsExists = value.String(), value.Exists() + if len(publicBefore)+len(publicAfter) > 0 { + parts := append(publicBefore, value.String()) + parts = append(parts, publicAfter...) + application.PublicInstructions, application.PublicInstructionsExists = strings.Join(nonEmptyPromptParts(parts), "\n\n"), true + } + return sjson.SetBytes(body, "instructions", application.FinalInstructions) +} + +func promptTextBlock(text string, claude bool) json.RawMessage { + block := map[string]string{"text": text} + if claude { + block["type"] = "text" + } + raw, _ := json.Marshal(block) + return raw +} + +func promptStructuredBlocks(placement extensionv1.PromptRulePlacement) ([]json.RawMessage, error) { + if placement.ContentFormat == "" || placement.ContentFormat == "text" { + return []json.RawMessage{promptTextBlock(placement.Body, true)}, nil + } + if placement.ContentFormat != extensionv1.PromptContentAnthropicSystemBlocks { + return nil, ErrPromptDeliveryUnsupported + } + var blocks []json.RawMessage + if !gjson.ParseBytes(placement.StructuredContent).IsArray() || json.Unmarshal(placement.StructuredContent, &blocks) != nil { + return nil, fmt.Errorf("%w: structured system content must be a block array", ErrBusinessSystemPromptInvalid) + } + for _, block := range blocks { + view := gjson.ParseBytes(block) + if !view.IsObject() || view.Get("type").String() != "text" || view.Get("text").Type != gjson.String { + return nil, fmt.Errorf("%w: invalid structured system text block", ErrBusinessSystemPromptInvalid) + } + } + return blocks, nil +} + +func applyPromptSystemBlocks(body []byte, field string, application *BusinessSystemPromptApplication, indices []int, applied []bool) ([]byte, error) { + value, _, unique := uniquePromptField(body, field) + if !unique { + return nil, fmt.Errorf("%w: duplicate system carrier", ErrBusinessSystemPromptInvalid) + } + existing := []json.RawMessage{} + systemObject := []byte(`{}`) + if field == "system" { + if value.Type == gjson.String { + existing = append(existing, promptTextBlock(value.String(), true)) + } else if value.Exists() { + if !value.IsArray() || json.Unmarshal([]byte(value.Raw), &existing) != nil { + return nil, fmt.Errorf("%w: system must be a string or block array", ErrBusinessSystemPromptInvalid) + } + } + } else if value.Exists() { + if !value.IsObject() { + return nil, fmt.Errorf("%w: systemInstruction must be an object", ErrBusinessSystemPromptInvalid) + } + systemObject = []byte(value.Raw) + parts, _, unique := uniquePromptField(systemObject, "parts") + if !unique { + return nil, fmt.Errorf("%w: duplicate systemInstruction.parts", ErrBusinessSystemPromptInvalid) + } + if parts.Exists() && (!parts.IsArray() || json.Unmarshal([]byte(parts.Raw), &existing) != nil) { + return nil, fmt.Errorf("%w: systemInstruction.parts must be an array", ErrBusinessSystemPromptInvalid) + } + } + before, after := []json.RawMessage{}, []json.RawMessage{} + beforeRefs, afterRefs := map[int]int{}, map[int]int{} + for _, index := range indices { + placement := &application.RulesPlan.Placements[index] + var blocks []json.RawMessage + var err error + if field == "system" { + blocks, err = promptStructuredBlocks(*placement) + } else { + if placement.ContentFormat != "" && placement.ContentFormat != "text" { + return nil, ErrPromptDeliveryUnsupported + } + blocks = []json.RawMessage{promptTextBlock(placement.Body, false)} + } + if err != nil { + return nil, err + } + if len(blocks) == 0 { + application.RulesPlan.Skipped = append(application.RulesPlan.Skipped, extensionv1.PromptRuleDecision{RuleID: placement.RuleID, Reason: "empty_content"}) + continue + } + switch placement.Position { + case extensionv1.PromptPositionControlPrepend: + beforeRefs[index] = len(before) + before = append(before, blocks...) + case extensionv1.PromptPositionControlAppend: + afterRefs[index] = len(after) + after = append(after, blocks...) + default: + return nil, ErrPromptDeliveryUnsupported + } + applied[index] = true + } + if len(before)+len(after) == 0 { + return body, nil + } + for index, position := range beforeRefs { + application.RulesPlan.Placements[index].Index = &position + } + for index, position := range afterRefs { + position += len(before) + len(existing) + application.RulesPlan.Placements[index].Index = &position + } + combined := append(before, existing...) + combined = append(combined, after...) + raw, err := json.Marshal(combined) + if err != nil { + return nil, err + } + if field == "systemInstruction" { + raw, err = sjson.SetRawBytes(systemObject, "parts", raw) + if err != nil { + return nil, err + } + } + return sjson.SetRawBytes(body, field, raw) +} + +func applyPromptMessages(body []byte, field string, application *BusinessSystemPromptApplication, indices []int, applied []bool) ([]byte, error) { + value, _, unique := uniquePromptField(body, field) + if !unique { + return nil, fmt.Errorf("%w: duplicate message carrier", ErrBusinessSystemPromptInvalid) + } + items := []json.RawMessage{} + if field == "input" && value.Type == gjson.String { + message, _ := json.Marshal(map[string]string{"role": "user", "content": value.String()}) + items = append(items, message) + } else if value.Exists() { + if !value.IsArray() || json.Unmarshal([]byte(value.Raw), &items) != nil { + return nil, fmt.Errorf("%w: %s must be a message array", ErrBusinessSystemPromptInvalid, field) + } + } else if field == "messages" { + return nil, fmt.Errorf("%w: messages must be an array", ErrBusinessSystemPromptInvalid) + } + controlEnd, lastUser := 0, -1 + for controlEnd < len(items) { + role := gjson.GetBytes(items[controlEnd], "role").String() + if role != "system" && role != "developer" { + break + } + controlEnd++ + } + for index, item := range items { + if promptItemIsUserInput(item) { + lastUser = index + } + } + insertions := map[int][]int{} + for _, index := range indices { + placement := &application.RulesPlan.Placements[index] + if placement.Role != "system" && placement.Role != "developer" { + return nil, ErrPromptDeliveryUnsupported + } + at, reason := 0, "" + switch placement.Position { + case extensionv1.PromptPositionControlPrepend, extensionv1.PromptPositionConversationHead: + case extensionv1.PromptPositionControlAppend: + at = controlEnd + case extensionv1.PromptPositionConversationTail: + at = len(items) + case extensionv1.PromptPositionBeforeLastUser: + if lastUser < 0 { + reason = "last_user_missing" + } else { + at = lastUser + } + case extensionv1.PromptPositionAfterLastUser: + if lastUser < 0 { + reason = "last_user_missing" + } else { + at = lastUser + 1 + } + default: + return nil, ErrPromptDeliveryUnsupported + } + if reason == "" && !promptToolBoundarySafe(items, at) { + reason = "unsafe_tool_boundary" + } + if reason == "" && placement.Protocol == BusinessSystemPromptProtocolMessages && !claudeSystemBoundarySafe(items, at) { + reason = "illegal_message_boundary" + } + if reason != "" { + application.RulesPlan.Skipped = append(application.RulesPlan.Skipped, extensionv1.PromptRuleDecision{RuleID: placement.RuleID, Reason: reason}) + continue + } + applied[index] = true + insertions[at] = append(insertions[at], index) + } + if len(insertions) == 0 { + return body, nil + } + combined := make([]json.RawMessage, 0, len(items)+len(indices)) + for at := 0; at <= len(items); at++ { + queued := insertions[at] + if len(queued) > 0 && application.RulesPlan.Placements[queued[0]].Protocol == BusinessSystemPromptProtocolMessages { + // Adjacent Claude system messages violate its position contract. + // Coalesce only our insertions, preserving rule order and each block. + blocks := []json.RawMessage{} + for _, index := range queued { + placement := &application.RulesPlan.Placements[index] + position, blockIndex := len(combined), len(blocks) + placement.Index, placement.BlockIndex = &position, &blockIndex + blocks = append(blocks, promptTextBlock(placement.Body, true)) + } + message, _ := json.Marshal(map[string]any{"role": "system", "content": blocks}) + combined = append(combined, message) + } else { + for _, index := range queued { + placement := &application.RulesPlan.Placements[index] + position := len(combined) + placement.Index = &position + message, _ := json.Marshal(map[string]string{"role": placement.Role, "content": placement.Body}) + combined = append(combined, message) + } + } + if at < len(items) { + combined = append(combined, items[at]) + } + } + raw, err := json.Marshal(combined) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, field, raw) +} + +func promptItemIsUserInput(raw json.RawMessage) bool { + item := gjson.ParseBytes(raw) + if item.Get("role").String() != "user" { + return false + } + content := item.Get("content") + if content.Type == gjson.String { + return strings.TrimSpace(content.String()) != "" + } + if !content.IsArray() { + return false + } + for _, block := range content.Array() { + kind := block.Get("type").String() + if kind == "tool_result" || strings.HasSuffix(kind, "_tool_result") || strings.HasSuffix(kind, "_call_output") || kind == "tool_search_output" { + continue + } + if kind == "text" || kind == "input_text" { + if strings.TrimSpace(block.Get("text").String()) != "" { + return true + } + continue + } + if kind != "" { + return true + } + } + return false +} + +func promptItemHasToolResult(item gjson.Result) bool { + kind := item.Get("type").String() + if item.Get("role").String() == "tool" || item.Get("role").String() == "function" || strings.HasSuffix(kind, "_call_output") || kind == "tool_search_output" { + return true + } + for _, block := range item.Get("content").Array() { + kind := block.Get("type").String() + if kind == "tool_result" || strings.HasSuffix(kind, "_tool_result") { + return true + } + } + return false +} + +func promptToolBoundarySafe(items []json.RawMessage, index int) bool { + if index < len(items) && promptItemHasToolResult(gjson.ParseBytes(items[index])) { + return false + } + outstanding := map[string]bool{} + for _, raw := range items[:index] { + item := gjson.ParseBytes(raw) + kind := item.Get("type").String() + // Server-side search / MCP calls are complete items, not half of an + // external call/result pair. Only calls with separate outputs open one. + if kind == "function_call" || kind == "custom_tool_call" || kind == "computer_call" || kind == "tool_search_call" { + id := item.Get("call_id").String() + if id == "" { + id = item.Get("id").String() + } + if id != "" { + outstanding[id] = true + } + } + if strings.HasSuffix(kind, "_call_output") || kind == "tool_search_output" { + delete(outstanding, item.Get("call_id").String()) + } + for _, call := range item.Get("tool_calls").Array() { + if id := call.Get("id").String(); id != "" { + outstanding[id] = true + } + } + if item.Get("role").String() == "tool" { + delete(outstanding, item.Get("tool_call_id").String()) + } + // Legacy Chat function calls have no IDs but still form an atomic pair. + if name := item.Get("function_call.name").String(); name != "" { + outstanding["function:"+name] = true + } + if item.Get("role").String() == "function" { + delete(outstanding, "function:"+item.Get("name").String()) + } + for _, block := range item.Get("content").Array() { + kind := block.Get("type").String() + if kind == "tool_use" || kind == "server_tool_use" { + if id := block.Get("id").String(); id != "" { + outstanding[id] = true + } + } + if kind == "tool_result" || strings.HasSuffix(kind, "_tool_result") { + delete(outstanding, block.Get("tool_use_id").String()) + } + } + } + return len(outstanding) == 0 +} + +func claudeSystemBoundarySafe(items []json.RawMessage, index int) bool { + if index == 0 { + return false + } + before := gjson.ParseBytes(items[index-1]) + previousAllowed := before.Get("role").String() == "user" + if before.Get("role").String() == "assistant" { + blocks := before.Get("content").Array() + if len(blocks) > 0 { + previousAllowed = strings.HasSuffix(blocks[len(blocks)-1].Get("type").String(), "_tool_result") + } + } + return previousAllowed && (index == len(items) || gjson.GetBytes(items[index], "role").String() == "assistant") +} diff --git a/backend/internal/service/business_system_prompt_rules_observation.go b/backend/internal/service/business_system_prompt_rules_observation.go index abb5b53e5d91..b42f1ff06279 100644 --- a/backend/internal/service/business_system_prompt_rules_observation.go +++ b/backend/internal/service/business_system_prompt_rules_observation.go @@ -24,9 +24,3 @@ func observePromptRulesFinal(c *gin.Context, account *Account, protocol string, slog.Info("prompt_rules_final", "account_id", account.ID, "protocol", protocol, "revision", application.Revision, "applied", application.Applied, "plan_sha256", application.RulesPlan.SHA256, "placements", placements, "skipped", application.RulesPlan.Skipped, "wire_verified", true) } - -func observePromptRulesFinalFromRequest(c *gin.Context, account *Account, protocol string) { - if application, ok := businessSystemPromptApplicationFromRequest(c, protocol); ok { - observePromptRulesFinal(c, account, protocol, application) - } -} diff --git a/backend/internal/service/business_system_prompt_rules_preview.go b/backend/internal/service/business_system_prompt_rules_preview.go index 3927a849c69b..c1eecb91d9b5 100644 --- a/backend/internal/service/business_system_prompt_rules_preview.go +++ b/backend/internal/service/business_system_prompt_rules_preview.go @@ -10,17 +10,20 @@ import ( extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) type PromptRulesPreviewRequest struct { + Model string `json:"model,omitempty"` Protocol string `json:"protocol"` Transport string `json:"transport"` Compact bool `json:"compact"` Body json.RawMessage `json:"body"` Policy *extensionv1.PromptRulePolicy `json:"policy,omitempty"` + Contents map[string]PromptContentDraft `json:"contents,omitempty"` Binding *extensionv1.PromptAccountBinding `json:"binding,omitempty"` SimulateEnabled *bool `json:"simulate_enabled,omitempty"` } @@ -51,7 +54,7 @@ func (s *BusinessSystemPromptService) PreviewPromptRules(ctx context.Context, ac if err != nil { return result, err } - if account == nil || !account.IsOpenAI() { + if !supportsPromptAccount(account) { return result, ErrPromptDeliveryUnsupported } snapshot, ok := s.CurrentSnapshot() @@ -60,15 +63,18 @@ func (s *BusinessSystemPromptService) PreviewPromptRules(ctx context.Context, ac } if request.Policy != nil { snapshot.RulePolicy = request.Policy - if err := s.preparePromptRulesSnapshot(&snapshot); err != nil { - return result, err - } result.Simulated = true } if request.SimulateEnabled != nil { snapshot.Enabled = *request.SimulateEnabled result.Simulated = true } + if request.Policy != nil || len(request.Contents) > 0 || (request.SimulateEnabled != nil && *request.SimulateEnabled) { + if err := s.preparePromptRulesDraft(ctx, &snapshot, request.Contents); err != nil { + return result, err + } + result.Simulated = true + } if request.Binding != nil { cloned := *account cloned.Extra = maps.Clone(account.Extra) @@ -79,13 +85,52 @@ func (s *BusinessSystemPromptService) PreviewPromptRules(ctx context.Context, ac account = &cloned result.Simulated = true } - before, protocol, base, err := s.preparePromptRulesPreviewBody(account, request) + ginContext := &gin.Context{} + ginContext.Request, _ = http.NewRequestWithContext(ctx, http.MethodPost, "/preview", nil) + // Preview uses the selected account binding, including an unsaved override. + // It must never refresh that selection from a live account during preparation. + snapshot.Draft = true + businessSystemPromptRequestSet(ginContext, businessSystemPromptContextKey(ginContext, businessSystemPromptRequestSnapshotKey, ""), snapshot) + requested := strings.TrimSpace(gjson.GetBytes(request.Body, "model").String()) + if requested == "" { + requested = strings.TrimSpace(request.Model) + } + if requested == "" { + return result, fmt.Errorf("%w: preview model is required", ErrBusinessSystemPromptInvalid) + } + mapped := resolveOpenAIForwardModel(account, requested, "") + if mapped == "" { + mapped = requested + } + mapped = normalizeOpenAIModelForUpstream(account, mapped) + businessSystemPromptRequestSet(ginContext, promptRequestedModelContextKey, requested) + var before []byte + var protocol, base, profile string + if account.Platform == PlatformAnthropic || account.Platform == PlatformGemini || account.Platform == PlatformAntigravity || account.IsAnthropicProtocol() { + ingress := request.Protocol + if ingress == "" { + ingress = BusinessSystemPromptProtocolResponses + } + mapped, err = ResolvePromptGatewayPreviewModel(account, ingress, request.Body, requested) + if err != nil { + return result, err + } + before, protocol, base, profile, err = PreparePromptGatewayPreview(ctx, ginContext, account, ingress, request.Body, mapped, s.previewSettings, s) + if account.IsAnthropicOAuthOrSetupToken() || account.Platform == PlatformAntigravity { + result.Simulated = true + } + } else { + before, protocol, base, err = s.preparePromptRulesPreviewBody(account, request) + } if err != nil { return result, err } result.Protocol, result.BeforeRules, result.GatewayBaseInstructions = protocol, before, base - result.RequestedModel = gjson.GetBytes(request.Body, "model").String() + result.RequestedModel = requested result.UpstreamModel = gjson.GetBytes(before, "model").String() + if protocol == BusinessSystemPromptProtocolGemini || result.UpstreamModel == "" { + result.UpstreamModel = mapped + } result.ClientControl = promptPreviewClientControl(request.Body) result.Continuation = gjson.GetBytes(before, "previous_response_id").String() != "" || gjson.GetBytes(before, "input.#(type==\"compaction\")").Exists() if result.Transport == "" { @@ -99,14 +144,27 @@ func (s *BusinessSystemPromptService) PreviewPromptRules(ctx context.Context, ac } target := businessSystemPromptTargetForAccount(account, protocol, request.Compact) target.RequestedModel, target.UpstreamModel = result.RequestedModel, result.UpstreamModel + target.RequestProfile = profile + snapshot, target, err = s.prepareAnthropicPromptSend(ginContext, account, before, snapshot, target) + if err != nil { + return result, err + } updated, application, err := ApplyBusinessSystemPromptToJSONContext(ctx, before, snapshot, target) if err != nil { return result, err } + if protocol == "messages" { + billingUA := "" + if profile == "generic-mimic" { + billingUA = claude.DefaultUserAgent() + } + updated, application, err = FinalizePromptMessageApplication(updated, application, account, s.previewSettings, billingUA, ginContext) + if err != nil { + return result, err + } + } // This is the identical applier and final carrier validator used at live // HTTP/WS write boundaries. No credentials, ticket, network, or model IO. - ginContext := &gin.Context{} - ginContext.Request, _ = http.NewRequestWithContext(ctx, http.MethodPost, "/preview", nil) businessSystemPromptRequestSet(ginContext, businessSystemPromptContextKey(ginContext, businessSystemPromptRequestApplicationKey, protocol), cacheBusinessSystemPromptState(before, updated, snapshot, target, application)) if err := validateBusinessSystemPromptFinal(ginContext, updated, protocol); err != nil { return result, err @@ -255,7 +313,7 @@ func (s *BusinessSystemPromptService) preparePromptRulesPreviewBody(account *Acc func promptPreviewClientControl(body []byte) map[string]any { control := map[string]any{} - for _, field := range []string{"instructions", "system"} { + for _, field := range []string{"instructions", "system", "systemInstruction"} { value := gjson.GetBytes(body, field) if value.Exists() { control[field] = value.Value() diff --git a/backend/internal/service/business_system_prompt_rules_service.go b/backend/internal/service/business_system_prompt_rules_service.go index fe0ad7a2f40d..3c405da7a1f1 100644 --- a/backend/internal/service/business_system_prompt_rules_service.go +++ b/backend/internal/service/business_system_prompt_rules_service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "slices" "time" @@ -32,54 +31,7 @@ func (s *BusinessSystemPromptService) loadPromptSnapshot(ctx context.Context) (B } func (s *BusinessSystemPromptService) preparePromptRulesSnapshot(snapshot *BusinessSystemPromptSnapshot) error { - var normalized extensionv1.PromptRulePolicy - if err := invokePromptManagement(context.Background(), "prompt.rules.validate", *snapshot.RulePolicy, &normalized); err != nil { - return err - } - snapshot.RulePolicy = &normalized - snapshot.ResolvedRules = nil - total := 0 - for _, rule := range normalized.Rules { - if !rule.Enabled { - continue - } - if rule.FollowActive { - rule.TemplateID, rule.VersionID = snapshot.TemplateID, snapshot.VersionID - } - if rule.TemplateID == 0 || rule.VersionID == 0 { - if !snapshot.Enabled { - continue - } - return ErrBusinessSystemPromptVersionNotFound - } - detail, err := s.store.GetBusinessSystemPromptTemplate(context.Background(), rule.TemplateID) - if err != nil { - return err - } - index := slices.IndexFunc(detail.Versions, func(version BusinessSystemPromptVersion) bool { return version.ID == rule.VersionID }) - if index < 0 { - return ErrBusinessSystemPromptVersionNotFound - } - version := detail.Versions[index] - 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 err := s.prepareBusinessSystemPromptSnapshot(&content); err != nil { - return err - } - content, err = s.compileBusinessSystemPromptSnapshot(content) - if err != nil { - return err - } - hash, size, err := validateBusinessSystemPromptBodyWithLimit(content.Body, extensionv1.PromptRulesMaxBytes) - if err != nil { - return err - } - total += size - if total > extensionv1.PromptRulesMaxBytes { - return fmt.Errorf("%w: compiled rule set exceeds 256 KiB", ErrBusinessSystemPromptInvalid) - } - snapshot.ResolvedRules = append(snapshot.ResolvedRules, extensionv1.ResolvedPromptRule{Rule: rule, Body: content.Body, SHA256: hash, PreserveEcho: version.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid}) - } - return nil + return s.compilePromptRules(context.Background(), snapshot, nil, false) } type PromptRulePolicyState struct { @@ -183,7 +135,7 @@ func (s *BusinessSystemPromptService) PromptAccountBindings(ctx context.Context, case "custom": effective = binding.RuleIDs } - views = append(views, PromptAccountBindingView{AccountID: account.ID, Name: account.Name, Platform: account.Platform, AccountType: account.Type, UpdatedAt: account.UpdatedAt, Binding: binding, EffectiveRuleIDs: effective, Supported: account.IsOpenAI()}) + views = append(views, PromptAccountBindingView{AccountID: account.ID, Name: account.Name, Platform: account.Platform, AccountType: account.Type, UpdatedAt: account.UpdatedAt, Binding: binding, EffectiveRuleIDs: effective, Supported: supportsPromptAccount(account)}) } return views, nil } @@ -249,7 +201,7 @@ func (s *BusinessSystemPromptService) UpdatePromptAccountBindings(ctx context.Co results := make([]PromptBindingUpdateResult, 0, len(updates)) for _, update := range updates { index := slices.IndexFunc(accounts, func(account *Account) bool { return account.ID == update.AccountID }) - if index < 0 || !accounts[index].IsOpenAI() { + if index < 0 || !supportsPromptAccount(accounts[index]) { results = append(results, PromptBindingUpdateResult{AccountID: update.AccountID, Code: "account_not_supported"}) continue } diff --git a/backend/internal/service/business_system_prompt_rules_test.go b/backend/internal/service/business_system_prompt_rules_test.go index c77d8b765f81..0218bbf12bad 100644 --- a/backend/internal/service/business_system_prompt_rules_test.go +++ b/backend/internal/service/business_system_prompt_rules_test.go @@ -17,6 +17,9 @@ import ( func promptRulesFixture(t *testing.T, protocol string, placements ...extensionv1.PromptRulePlacement) BusinessSystemPromptApplication { t.Helper() + for i := range placements { + placements[i].Protocol = protocol + } return BusinessSystemPromptApplication{Applied: true, Revision: 1, Carrier: "rule_plan", RulesPlan: &extensionv1.PromptRulesPlan{SHA256: "plan", Placements: placements}} } @@ -86,9 +89,9 @@ func TestPromptRulesMixedPublicPrivateEchoPreservesClient(t *testing.T) { } func TestPromptRulesPlannerRejectsOAuthSystemBeforeMutation(t *testing.T) { - rule := extensionv1.PromptRule{ID: "r", Name: "r", Enabled: true, TemplateID: 1, VersionID: 1, Delivery: "system", Position: "control_append"} + rule := extensionv1.PromptRule{ID: "r", Name: "r", Enabled: true, TemplateID: 1, VersionID: 1, Role: "system", Platforms: []string{"openai"}, Position: "control_append"} hash, _, _ := extensionv1.ValidateTextDocument("site", 100) - snapshot := BusinessSystemPromptSnapshot{Enabled: true, RulePolicy: &extensionv1.PromptRulePolicy{Version: 1, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"r"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site", SHA256: hash}}} + snapshot := BusinessSystemPromptSnapshot{Enabled: true, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"r"}}, ResolvedRules: []extensionv1.ResolvedPromptRule{{Rule: rule, Body: "site", SHA256: hash}}} _, err := promptpolicy.Plan(snapshot, BusinessSystemPromptTarget{Platform: "openai", AccountType: "oauth", Protocol: "responses"}, true) require.ErrorIs(t, err, promptpolicy.ErrPromptDeliveryUnsupported) } @@ -99,16 +102,13 @@ func TestPromptRulesLegacyHybridAndMaximumCompiledBody(t *testing.T) { service := newGatewayHybridBusinessSystemPromptPolicy(t) snapshot, ok := service.CurrentSnapshot() require.True(t, ok) - old, err := service.compileBusinessSystemPromptSnapshot(snapshot) - require.NoError(t, err) - store, ok := service.store.(*fakeBusinessSystemPromptStore) - require.True(t, ok) - store.detail = BusinessSystemPromptTemplateDetail{Versions: []BusinessSystemPromptVersion{{ID: snapshot.VersionID, TemplateID: snapshot.TemplateID, Body: snapshot.Body, SHA256: snapshot.SHA256, ByteLength: snapshot.ByteLength, CompositionMode: snapshot.CompositionMode, BundleID: snapshot.BundleID}}} - snapshot.RulePolicy = &extensionv1.PromptRulePolicy{Version: 1, DefaultRuleIDs: []string{"legacy-default"}, Rules: []extensionv1.PromptRule{{ID: "legacy-default", Name: "Legacy default", Enabled: true, FollowActive: true, Delivery: "native_control", Position: "control_append"}}} + require.Len(t, snapshot.ResolvedRules, 1) + old := snapshot.ResolvedRules[0] + snapshot.RulePolicy = &extensionv1.PromptRulePolicy{Version: 2, DefaultRuleIDs: []string{"legacy-default"}, Rules: []extensionv1.PromptRule{{ID: "legacy-default", Name: "Legacy default", Enabled: true, TemplateID: snapshot.TemplateID, VersionID: snapshot.VersionID, Role: "auto", Platforms: []string{"openai", "cindy"}, Position: "control_append"}}} require.NoError(t, service.preparePromptRulesSnapshot(&snapshot)) require.Len(t, snapshot.ResolvedRules, 1) require.Equal(t, old.Body, snapshot.ResolvedRules[0].Body) - require.Equal(t, old.EffectiveSHA256, snapshot.ResolvedRules[0].SHA256) + require.Equal(t, old.SHA256, snapshot.ResolvedRules[0].SHA256) }) t.Run("full_256_KiB_compiled_publication", func(t *testing.T) { body := strings.Repeat("x", BusinessSystemPromptBundleMaxBytes) @@ -120,7 +120,7 @@ func TestPromptRulesLegacyHybridAndMaximumCompiledBody(t *testing.T) { store := &fakeBusinessSystemPromptStore{detail: BusinessSystemPromptTemplateDetail{Versions: []BusinessSystemPromptVersion{{ID: 2, TemplateID: 1, Body: "base", SHA256: baseHash, ByteLength: baseSize, CompositionMode: BusinessSystemPromptCompositionCodexSkillHybrid, BundleID: BusinessSystemPromptRemoteSkillBundleID}}}} service := NewBusinessSystemPromptService(store, nil) service.registry = registry - snapshot := BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, TemplateID: 1, VersionID: 2, RulePolicy: &extensionv1.PromptRulePolicy{Version: 1, DefaultRuleIDs: []string{"legacy-default"}, Rules: []extensionv1.PromptRule{{ID: "legacy-default", Name: "Legacy", Enabled: true, FollowActive: true, Delivery: "native_control", Position: "control_append"}}}} + snapshot := BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, TemplateID: 1, VersionID: 2, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, DefaultRuleIDs: []string{"legacy-default"}, Rules: []extensionv1.PromptRule{{ID: "legacy-default", Name: "Legacy", Enabled: true, TemplateID: 1, VersionID: 2, Role: "auto", Platforms: []string{"openai", "cindy"}, Position: "control_append"}}}} require.NoError(t, service.preparePromptRulesSnapshot(&snapshot)) output, application, err := ApplyBusinessSystemPromptToJSONContext(context.Background(), []byte(`{"input":"hello"}`), snapshot, BusinessSystemPromptTarget{Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, Protocol: "responses"}) require.NoError(t, err) diff --git a/backend/internal/service/business_system_prompt_rules_v2_test.go b/backend/internal/service/business_system_prompt_rules_v2_test.go new file mode 100644 index 000000000000..b16faf40bfb4 --- /dev/null +++ b/backend/internal/service/business_system_prompt_rules_v2_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func unifiedPromptSnapshot(t *testing.T, platform string, roles, positions, bodies []string) BusinessSystemPromptSnapshot { + t.Helper() + snapshot := BusinessSystemPromptSnapshot{Enabled: true, Revision: 42, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2}} + for i, role := range roles { + rule := extensionv1.PromptRule{ID: fmt.Sprintf("rule-%d", i), Name: fmt.Sprintf("Rule %d", i), Enabled: true, TemplateID: int64(i + 1), VersionID: int64(i + 1), Role: role, Position: positions[i], Platforms: []string{platform}, ModelMatch: "upstream"} + if platform == "anthropic" && (positions[i] == "conversation_tail" || positions[i] == "after_last_user") { + rule.Models = []string{"claude-opus-4-8"} + } + hash, _, err := extensionv1.ValidateTextDocument(bodies[i], extensionv1.PromptRulesMaxBytes) + require.NoError(t, err) + snapshot.RulePolicy.Rules = append(snapshot.RulePolicy.Rules, rule) + snapshot.RulePolicy.DefaultRuleIDs = append(snapshot.RulePolicy.DefaultRuleIDs, rule.ID) + snapshot.ResolvedRules = append(snapshot.ResolvedRules, extensionv1.ResolvedPromptRule{Rule: rule, Body: bodies[i], SHA256: hash}) + } + return snapshot +} + +func TestUnifiedPromptChatSixPositionsAndRealRoles(t *testing.T) { + input := []byte(`{"instructions":"unrelated","messages":[{"role":"system","content":"client-system"},{"role":"developer","content":"client-developer"},{"role":"user","content":[{"type":"text","text":"first"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]},{"role":"assistant","content":"reply"},{"role":"user","content":"last"}]}`) + positions := []string{"control_prepend", "control_append", "conversation_head", "conversation_tail", "before_last_user", "after_last_user"} + expected := []int{0, 2, 0, 5, 4, 5} + for _, role := range []string{"auto", "system", "developer"} { + for n, position := range positions { + t.Run(role+"/"+position, func(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "openai", []string{role}, []string{position}, []string{"site"}) + out, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "chat"}) + require.NoError(t, err) + require.True(t, app.Applied) + require.Equal(t, "unrelated", gjson.GetBytes(out, "instructions").String()) + messages := gjson.GetBytes(out, "messages").Array() + actualRole := role + if actualRole == "auto" { + actualRole = "system" + } + require.Equal(t, actualRole, messages[expected[n]].Get("role").String()) + require.Equal(t, "site", messages[expected[n]].Get("content").String()) + require.Equal(t, expected[n], *app.RulesPlan.Placements[0].Index) + originals := append(messages[:expected[n]], messages[expected[n]+1:]...) + for i, original := range gjson.GetBytes(input, "messages").Array() { + require.JSONEq(t, original.Raw, originals[i].Raw) + } + }) + } + } +} + +func TestUnifiedPromptResponsesNativeExplicitAndStringInput(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "openai", []string{"auto", "auto", "developer", "system"}, []string{"control_prepend", "control_append", "before_last_user", "after_last_user"}, []string{"before", "after", "dev", "sys"}) + input := []byte(`{"instructions":" exact client\n","input":"hello","previous_response_id":"resp_prior"}`) + out, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses", AccountType: "apikey"}) + require.NoError(t, err) + require.Equal(t, "before\n\n exact client\n\n\nafter", gjson.GetBytes(out, "instructions").String()) + require.Equal(t, "developer", gjson.GetBytes(out, "input.0.role").String()) + require.Equal(t, "hello", gjson.GetBytes(out, "input.1.content").String()) + require.Equal(t, "system", gjson.GetBytes(out, "input.2.role").String()) + require.Equal(t, "resp_prior", gjson.GetBytes(out, "previous_response_id").String()) + require.Len(t, app.RulesPlan.Placements, 4) + require.Equal(t, " exact client\n", app.ClientInstructions) + require.Nil(t, snapshot.ResolvedRules[0].StructuredContent) + // A pure inline request does not start or consult a Skill registry / RPC. + history := strings.Repeat("history", extensionv1.MaxPayloadBytes/7+1) + body, _ := json.Marshal(map[string]any{"input": history}) + out, _, err = ApplyBusinessSystemPromptToJSONContext(context.Background(), body, unifiedPromptSnapshot(t, "openai", []string{"auto"}, []string{"control_append"}, []string{"inline"}), BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}) + require.NoError(t, err) + require.Equal(t, history, gjson.GetBytes(out, "input").String()) +} + +func TestUnifiedPromptMissingAnchorAndUnsafeToolBoundariesSkip(t *testing.T) { + cases := []struct{ name, protocol, platform, body, position, reason string }{ + {"no user", "responses", "openai", `{"input":[{"role":"assistant","content":"answer"}]}`, "before_last_user", "last_user_missing"}, + {"Claude tool results are not users", "messages", "anthropic", `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"call","content":"output"}]}]}`, "after_last_user", "last_user_missing"}, + {"open Chat tool call", "chat", "openai", `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","tool_calls":[{"id":"one","type":"function","function":{"name":"f","arguments":"{}"}}]}]}`, "conversation_tail", "unsafe_tool_boundary"}, + {"parallel Responses tool group", "responses", "openai", `{"input":[{"type":"function_call","call_id":"one"},{"type":"function_call","call_id":"two"},{"type":"function_call_output","call_id":"one","output":"ok"},{"role":"user","content":"interrupt"},{"type":"function_call_output","call_id":"two","output":"ok"}]}`, "before_last_user", "unsafe_tool_boundary"}, + {"Claude illegal assistant tail", "messages", "anthropic", `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"done"}]}`, "conversation_tail", "illegal_message_boundary"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, tc.platform, []string{"auto"}, []string{tc.position}, []string{"site"}) + out, app, err := ApplyBusinessSystemPromptToJSON([]byte(tc.body), snapshot, BusinessSystemPromptTarget{Platform: tc.platform, Protocol: tc.protocol, UpstreamModel: "claude-opus-4-8"}) + require.NoError(t, err) + require.Equal(t, tc.body, string(out)) + require.False(t, app.Applied) + require.Empty(t, app.RulesPlan.Placements) + require.Equal(t, 0, app.EffectiveByteLength) + require.Equal(t, tc.reason, app.RulesPlan.Skipped[0].Reason) + }) + } +} + +func TestUnifiedPromptClaudeBlocksAndSameBoundaryOrder(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "anthropic", []string{"auto", "system", "auto", "system"}, []string{"control_prepend", "control_append", "after_last_user", "after_last_user"}, []string{"top-before", "top-after", "message-first", "message-second"}) + input := []byte(`{"system":[{"type":"text","text":"client","cache_control":{"type":"ephemeral","ttl":"1h"},"citations":{"enabled":true}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image","source":{"type":"url","url":"https://example.invalid/a.png"}}]},{"role":"assistant","content":"answer"}]}`) + out, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "anthropic", Protocol: "messages", UpstreamModel: "claude-opus-4-8"}) + require.NoError(t, err) + require.JSONEq(t, gjson.GetBytes(input, "system.0").Raw, gjson.GetBytes(out, "system.1").Raw) + require.Equal(t, "top-before", gjson.GetBytes(out, "system.0.text").String()) + require.Equal(t, "top-after", gjson.GetBytes(out, "system.2.text").String()) + require.Equal(t, int64(3), gjson.GetBytes(out, "messages.#").Int()) + require.Equal(t, "system", gjson.GetBytes(out, "messages.1.role").String()) + require.Equal(t, "message-first", gjson.GetBytes(out, "messages.1.content.0.text").String()) + require.Equal(t, "message-second", gjson.GetBytes(out, "messages.1.content.1.text").String()) + require.Equal(t, 1, *app.RulesPlan.Placements[2].Index) + require.Equal(t, 0, *app.RulesPlan.Placements[2].BlockIndex) + require.Equal(t, 1, *app.RulesPlan.Placements[3].Index) + require.Equal(t, 1, *app.RulesPlan.Placements[3].BlockIndex) + require.JSONEq(t, gjson.GetBytes(input, "messages.0").Raw, gjson.GetBytes(out, "messages.0").Raw) + require.JSONEq(t, gjson.GetBytes(input, "messages.1").Raw, gjson.GetBytes(out, "messages.2").Raw) +} + +func TestUnifiedPromptStructuredContentAndGeminiCarrier(t *testing.T) { + t.Run("migrated Claude blocks keep metadata", func(t *testing.T) { + blocks := `[{"type":"text","text":"identity","cache_control":{"type":"ephemeral"}},{"type":"text","text":"custom","cache_control":{"type":"ephemeral","ttl":"1h"}}]` + snapshot := unifiedPromptSnapshot(t, "anthropic", []string{"auto"}, []string{"control_prepend"}, []string{blocks}) + snapshot.ResolvedRules[0].ContentFormat = extensionv1.PromptContentAnthropicSystemBlocks + snapshot.ResolvedRules[0].StructuredContent = json.RawMessage(blocks) + input := []byte(`{"system":"client","messages":[{"role":"user","content":"hi"}]}`) + out, _, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "anthropic", Protocol: "messages"}) + require.NoError(t, err) + require.Equal(t, "1h", gjson.GetBytes(out, "system.1.cache_control.ttl").String()) + require.Equal(t, "client", gjson.GetBytes(out, "system.2.text").String()) + require.JSONEq(t, gjson.GetBytes(input, "messages").Raw, gjson.GetBytes(out, "messages").Raw) + }) + t.Run("Gemini preserves system metadata and contents", func(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "antigravity", []string{"auto", "system"}, []string{"control_prepend", "control_append"}, []string{"before", "after"}) + input := []byte(`{"systemInstruction":{"role":"user","parts":[{"text":"client","custom":42}],"custom":"keep"},"contents":[{"role":"user","parts":[{"text":"hi"},{"inlineData":{"mimeType":"image/png","data":"AAAA"}}]}]}`) + out, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "antigravity", Protocol: "gemini"}) + require.NoError(t, err) + require.True(t, app.Applied) + require.Equal(t, "user", gjson.GetBytes(out, "systemInstruction.role").String()) + require.Equal(t, "keep", gjson.GetBytes(out, "systemInstruction.custom").String()) + require.Equal(t, "before", gjson.GetBytes(out, "systemInstruction.parts.0.text").String()) + require.JSONEq(t, gjson.GetBytes(input, "systemInstruction.parts.0").Raw, gjson.GetBytes(out, "systemInstruction.parts.1").Raw) + require.Equal(t, "after", gjson.GetBytes(out, "systemInstruction.parts.2.text").String()) + require.JSONEq(t, gjson.GetBytes(input, "contents").Raw, gjson.GetBytes(out, "contents").Raw) + }) +} + +func TestUnifiedPromptSkipsRehashActualPlanAndDoNotMutateSnapshot(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "openai", []string{"auto", "developer"}, []string{"control_append", "before_last_user"}, []string{"native", "skipped"}) + input := []byte(`{"input":[{"role":"assistant","content":"done"}]}`) + planned, err := PlanBusinessSystemPrompt(context.Background(), snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}) + require.NoError(t, err) + oldHash := planned.RulesPlan.SHA256 + out, app, err := applyPromptRules(input, planned) + require.NoError(t, err) + require.Equal(t, "native", gjson.GetBytes(out, "instructions").String()) + require.Len(t, app.RulesPlan.Placements, 1) + require.Len(t, app.RulesPlan.Skipped, 1) + require.NotEqual(t, oldHash, app.RulesPlan.SHA256) + require.Len(t, planned.RulesPlan.Placements, 2) + require.Empty(t, planned.RulesPlan.Skipped) + require.Equal(t, oldHash, planned.RulesPlan.SHA256) + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, _, err = ApplyBusinessSystemPromptToJSONContext(canceled, input, snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}) + require.ErrorIs(t, err, context.Canceled) +} + +func TestUnifiedPromptCompletedBuiltinAndExternalToolHistory(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "openai", []string{"developer"}, []string{"before_last_user"}, []string{"site"}) + input := []byte(`{"input":[{"type":"web_search_call","id":"ws_done","status":"completed"},{"type":"mcp_call","id":"mcp_done","output":"ok"},{"type":"tool_search_call","call_id":"search","arguments":{"query":"find"}},{"type":"tool_search_output","call_id":"search","tools":[]},{"role":"user","content":"next"}]}`) + out, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, BusinessSystemPromptTarget{Platform: "openai", Protocol: "responses"}) + require.NoError(t, err) + require.True(t, app.Applied) + require.Equal(t, "site", gjson.GetBytes(out, "input.4.content").String()) + for index, item := range gjson.GetBytes(input, "input").Array()[:4] { + require.JSONEq(t, item.Raw, gjson.GetBytes(out, fmt.Sprintf("input.%d", index)).Raw) + } +} diff --git a/backend/internal/service/business_system_prompt_rules_ws.go b/backend/internal/service/business_system_prompt_rules_ws.go index 86bf8c091214..45bdeaa22158 100644 --- a/backend/internal/service/business_system_prompt_rules_ws.go +++ b/backend/internal/service/business_system_prompt_rules_ws.go @@ -8,35 +8,15 @@ import ( coderws "github.com/coder/websocket" ) -const promptRulesDeferredWSKey = "prompt_rules_deferred_ws" - -// The native WS accumulator must retain clean customer history. Validate and -// freeze policy here, but insert explicit messages only after replay assembly. +// Freeze content when accepting the client turn, while retaining only clean +// messages in the replay accumulator. Placement is resolved after replay. func (s *OpenAIGatewayService) prepareBusinessPromptWSIngress(c *gin.Context, body []byte, account *Account, protocol string, compact bool) ([]byte, BusinessSystemPromptApplication, error) { - snapshot, eligible, err := s.businessSystemPromptSnapshotForRequest(c, account) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - if !eligible || snapshot.RulePolicy == nil { - return s.applyBusinessSystemPromptForRequest(c, body, account, protocol, compact) - } - target := enrichPromptTarget(c, body, businessSystemPromptTargetForAccount(account, protocol, compact)) - application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(c), body, snapshot, target, promptPlanInvoke) - if err != nil { - return nil, application, err - } - businessSystemPromptRequestSet(c, promptRulesDeferredWSKey, true) - return body, application, nil + _, _, err := s.businessSystemPromptSnapshotForRequest(c, account) + return body, BusinessSystemPromptApplication{}, err } func (s *OpenAIGatewayService) finalizeBusinessPromptWSIngress(c *gin.Context, account *Account, body []byte) ([]byte, error) { - if deferred, _ := businessSystemPromptRequestGet(c, promptRulesDeferredWSKey); deferred == true { - // sendAndRelay receives the accumulator's clean array, including on a - // verified full replay. Reuse frozen policy, not a prior attempt's edits. - businessSystemPromptRequestDelete(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolResponses)) - return s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) - } - return body, validateBusinessSystemPromptFinal(c, body, BusinessSystemPromptProtocolResponses) + return s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) } func businessPromptWSCloseError(err error) error { diff --git a/backend/internal/service/business_system_prompt_send.go b/backend/internal/service/business_system_prompt_send.go new file mode 100644 index 000000000000..2292e594c080 --- /dev/null +++ b/backend/internal/service/business_system_prompt_send.go @@ -0,0 +1,277 @@ +package service + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "strings" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" +) + +const businessSystemPromptRequestProfileKey = "business_system_prompt_request_profile" + +const businessSystemPromptStrictChatRoleKey = "business_system_prompt_strict_chat_role" +const anthropicPromptAttemptKey = "business_system_prompt_anthropic_attempt" + +type compiledAnthropicPromptSource struct { + blocks json.RawMessage + hash string +} + +// Only the bounded server-owned sources are retained here. The user's request +// remains owned by the gateway's clean body and is never duplicated in context. +type anthropicPromptAttempt struct { + accountID int64 + model string + binding string + sources map[string]compiledAnthropicPromptSource +} + +func setBusinessSystemPromptRequestProfile(c *gin.Context, account *Account, mimic bool) { + profile := "" + if account != nil && account.IsAnthropicOAuthOrSetupToken() { + profile = "claude-code" + if mimic { + profile = "generic-mimic" + } + } + businessSystemPromptRequestSet(c, businessSystemPromptRequestProfileKey, profile) +} + +func promptSendTarget(c *gin.Context, account *Account, body []byte, protocol string, compact bool, upstreamModel string) BusinessSystemPromptTarget { + target := enrichPromptTarget(c, body, businessSystemPromptTargetForAccount(account, protocol, compact)) + if protocol == BusinessSystemPromptProtocolChat { + if strict, exists := businessSystemPromptRequestGet(c, businessSystemPromptStrictChatRoleKey); exists { + target.ChatSystemRoleOnly, _ = strict.(bool) + } + } + if profile, exists := businessSystemPromptRequestGet(c, businessSystemPromptRequestProfileKey); exists { + target.RequestProfile, _ = profile.(string) + } + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + target.UpstreamModel = upstreamModel + if target.RequestedModel == "" { + target.RequestedModel = upstreamModel + } + } + return target +} + +func (s *BusinessSystemPromptService) freshPromptBinding(c *gin.Context, account *Account, target BusinessSystemPromptTarget) (BusinessSystemPromptTarget, error) { + if s.accountRepo != nil { + fresh, err := s.accountRepo.GetByID(promptPolicyRequestContext(c), account.ID) + if err != nil || fresh == nil { + return target, fmt.Errorf("%w: account binding unavailable", ErrBusinessSystemPromptUnavailable) + } + target.BindingJSON = businessSystemPromptTargetForAccount(fresh, target.Protocol, target.Compact).BindingJSON + } + return target, nil +} + +// HasAnthropicSystemBlocksForSend plans the same frozen selection used by the +// final send. A migrated structured source already contains its infrastructure +// block layout, so the mimicry adapter must not prepend a second default layout. +// This method never applies content or caches a partly prepared wire body. +func (s *BusinessSystemPromptService) HasAnthropicSystemBlocksForSend(c *gin.Context, account *Account, body []byte, model string) (bool, error) { + if s == nil || account == nil { + return false, nil + } + snapshot, err := s.snapshotForRequest(c) + if err != nil { + return false, err + } + target := promptSendTarget(c, account, body, "messages", false, model) + if !promptSnapshotNeedsBinding(snapshot, target) { + return false, nil + } + if !snapshot.Draft { + target, err = s.freshPromptBinding(c, account, target) + if err != nil { + return false, err + } + } + application, err := planBusinessSystemPrompt(promptPolicyRequestContext(c), snapshot, target) + if err != nil { + return false, err + } + attempt := anthropicPromptAttempt{accountID: account.ID, model: model, binding: target.BindingJSON, sources: map[string]compiledAnthropicPromptSource{}} + if application.RulesPlan != nil { + for _, placement := range application.RulesPlan.Placements { + if placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + source, err := compileAnthropicPromptSource(body, placement.Body) + if err != nil { + return false, err + } + attempt.sources[placement.RuleID] = source + } + } + } + businessSystemPromptRequestSet(c, anthropicPromptAttemptKey, attempt) + return len(attempt.sources) != 0, nil +} + +func compileAnthropicPromptSource(body []byte, source string) (compiledAnthropicPromptSource, error) { + blocks, err := ExpandClaudeOAuthSystemPromptBlocks(body, source) + if err != nil { + return compiledAnthropicPromptSource{}, fmt.Errorf("%w: structured prompt expansion", ErrBusinessSystemPromptInvalid) + } + hash, _, err := validateBusinessSystemPromptBodyWithLimit(string(blocks), extensionv1.PromptRulesMaxBytes) + if err != nil { + return compiledAnthropicPromptSource{}, err + } + return compiledAnthropicPromptSource{blocks: blocks, hash: hash}, nil +} + +// prepareAnthropicPromptSend consumes the pre-mimic source expansion and binding +// together. Their selection cannot diverge after the adapter omitted its base +// layout, even if an account is edited while this attempt is being prepared. +func (s *BusinessSystemPromptService) prepareAnthropicPromptSend(c *gin.Context, account *Account, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) (BusinessSystemPromptSnapshot, BusinessSystemPromptTarget, error) { + if !promptSnapshotNeedsBinding(snapshot, target) { + return snapshot, target, nil + } + var attempt anthropicPromptAttempt + matchedAttempt := false + if target.Protocol == "messages" && target.RequestProfile == "generic-mimic" { + if value, ok := businessSystemPromptRequestGet(c, anthropicPromptAttemptKey); ok { + attempt, matchedAttempt = value.(anthropicPromptAttempt) + matchedAttempt = matchedAttempt && attempt.accountID == account.ID && attempt.model == target.UpstreamModel + } + } + if matchedAttempt { + target.BindingJSON = attempt.binding + } else if !snapshot.Draft { + var err error + target, err = s.freshPromptBinding(c, account, target) + if err != nil { + return snapshot, target, err + } + } + if target.Protocol != "messages" { + return snapshot, target, nil + } + application, err := planBusinessSystemPrompt(promptPolicyRequestContext(c), snapshot, target) + if err != nil { + return snapshot, target, err + } + selected := map[string]bool{} + if application.RulesPlan != nil { + for _, placement := range application.RulesPlan.Placements { + if placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + selected[placement.RuleID] = true + } + } + } + if len(selected) == 0 { + return snapshot, target, nil + } + snapshot.ResolvedRules = append([]extensionv1.ResolvedPromptRule(nil), snapshot.ResolvedRules...) + for index := range snapshot.ResolvedRules { + rule := &snapshot.ResolvedRules[index] + if !selected[rule.Rule.ID] { + continue + } + source, ok := attempt.sources[rule.Rule.ID] + if !matchedAttempt || !ok { + source, err = compileAnthropicPromptSource(body, rule.Body) + if err != nil { + return snapshot, target, err + } + } + rule.Body, rule.StructuredContent, rule.SHA256 = string(source.blocks), source.blocks, source.hash + } + return snapshot, target, nil +} + +func promptSnapshotNeedsBinding(snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget) bool { + if !snapshot.Enabled || target.Compact && !snapshot.CompactEnabled || snapshot.RulePolicy == nil { + return false + } + for _, rule := range snapshot.RulePolicy.Rules { + if rule.Enabled { + return true + } + } + return false +} + +// snapshotForRequest freezes the compiled content for one HTTP request or WS +// turn. Account selection and its binding are deliberately not frozen here. +func (s *BusinessSystemPromptService) snapshotForRequest(c *gin.Context) (BusinessSystemPromptSnapshot, error) { + key := businessSystemPromptContextKey(c, businessSystemPromptRequestSnapshotKey, "") + if value, exists := businessSystemPromptRequestGet(c, key); exists { + if snapshot, ok := value.(BusinessSystemPromptSnapshot); ok { + return snapshot, nil + } + return BusinessSystemPromptSnapshot{}, ErrBusinessSystemPromptUnavailable + } + snapshot, ok := s.CurrentSnapshot() + if !ok { + return BusinessSystemPromptSnapshot{}, ErrBusinessSystemPromptUnavailable + } + // Reload and atomic saves already compile immutable rule versions. Freeze + // that publication directly; a request must not resolve a later registry or + // storage state while preparing its outbound body. + if snapshot.Enabled && snapshot.RulePolicy == nil { + return BusinessSystemPromptSnapshot{}, ErrBusinessSystemPromptUnavailable + } + businessSystemPromptRequestSet(c, key, snapshot) + return snapshot, nil +} + +// ApplyForSend applies the frozen rules to a clean, final-protocol request. +// Callers retain their clean body for retries, fallback and WS accumulation; +// the returned bytes belong only to this send. Apply before cache keys and +// signatures, after protocol conversion and required provider instructions. +func (s *BusinessSystemPromptService) ApplyForSend(c *gin.Context, account *Account, body []byte, protocol string, compact bool) ([]byte, BusinessSystemPromptApplication, error) { + return s.ApplyForSendModel(c, account, body, protocol, compact, "") +} + +// ApplyForSendModel is for protocols whose model is carried by the URL or an +// outer envelope. It never adds a synthetic model field to the wire body. +func (s *BusinessSystemPromptService) ApplyForSendModel(c *gin.Context, account *Account, body []byte, protocol string, compact bool, upstreamModel string) (out []byte, application BusinessSystemPromptApplication, returnErr error) { + defer func() { writePromptDeliveryError(c, returnErr) }() + target := promptSendTarget(c, account, body, protocol, compact, upstreamModel) + rememberBusinessSystemPromptTarget(c, target) + if s == nil || account == nil { + return body, BusinessSystemPromptApplication{}, nil + } + if value, exists := businessSystemPromptRequestGet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, protocol)); exists { + state, ok := value.(businessSystemPromptRequestState) + if ok && state.application.Applied && state.snapshot.Revision > 0 && sha256.Sum256(body) != state.inputHash && promptRulesCarrierMatches(body, state) { + return nil, BusinessSystemPromptApplication{}, fmt.Errorf("%w: a send body cannot be reused as the clean request", ErrBusinessSystemPromptUnavailable) + } + } + snapshot, err := s.snapshotForRequest(c) + if err != nil { + return nil, BusinessSystemPromptApplication{}, err + } + snapshot, target, err = s.prepareAnthropicPromptSend(c, account, body, snapshot, target) + if err != nil { + return nil, BusinessSystemPromptApplication{}, err + } + // The engine consumes only clean provider bytes. It is pure and therefore + // cannot return a cached wire body from another account or protocol. + updated, application, err := ApplyBusinessSystemPromptToJSONContext(promptPolicyRequestContext(c), body, snapshot, target) + if err != nil { + return nil, application, err + } + if protocol == "messages" { + billingUserAgent := "" + if value, exists := businessSystemPromptRequestGet(c, businessSystemPromptBillingUserAgentKey); exists { + billingUserAgent, _ = value.(string) + } else if target.RequestProfile == "generic-mimic" { + billingUserAgent = claude.DefaultUserAgent() + } + updated, application, err = FinalizePromptMessageApplication(updated, application, account, s.previewSettings, billingUserAgent, c) + if err != nil { + return nil, application, err + } + } + businessSystemPromptRequestSet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, protocol), cacheBusinessSystemPromptState(body, updated, snapshot, target, application)) + rememberBusinessSystemPromptTarget(c, target) + observePromptRulesFinal(c, account, protocol, application) + return updated, application, nil +} diff --git a/backend/internal/service/business_system_prompt_send_test.go b/backend/internal/service/business_system_prompt_send_test.go new file mode 100644 index 000000000000..08607d2bacbb --- /dev/null +++ b/backend/internal/service/business_system_prompt_send_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func promptSendPolicyForPlatform(platform, role, position string) *BusinessSystemPromptService { + service := newPromptRulesGatewayPolicy(role, position) + snapshot, _ := service.CurrentSnapshot() + rule := snapshot.RulePolicy.Rules[0] + rule.Platforms = []string{platform} + snapshot.RulePolicy.Rules[0] = rule + snapshot.ResolvedRules[0].Rule = rule + service.snapshot.Store(&snapshot) + return service +} + +func promptSendHTTPResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} +} + +func TestPromptSendNativeAnthropicFallbackAndGrokActualWire(t *testing.T) { + t.Run("native_anthropic", func(t *testing.T) { + account := businessSystemPromptAPIKeyAccount(true) + account.Platform = PlatformKimi + account.Credentials["api_protocol"] = APIProtocolAnthropic + account.Credentials["api_base_urls"] = map[string]any{APIProtocolAnthropic: "https://anthropic.example"} + policy := promptSendPolicyForPlatform(PlatformKimi, "auto", "control_append") + input := []byte(`{"model":"k3","max_tokens":32,"stream":false,"system":[{"type":"text","text":"client","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/messages", input) + upstream := &httpUpstreamRecorder{resp: promptSendHTTPResponse(200, `{"id":"msg_test","type":"message","role":"assistant","model":"k3","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)} + gateway := &OpenAIGatewayService{cfg: businessSystemPromptTestConfig(), httpUpstream: upstream, businessPromptService: policy} + _, err := gateway.ForwardAsAnthropic(context.Background(), c, account, input, "", "") + require.NoError(t, err) + require.Len(t, upstream.bodies, 1) + require.Equal(t, "site-rule-content", gjson.GetBytes(upstream.lastBody, "system.1.text").String()) + require.Equal(t, "ephemeral", gjson.GetBytes(upstream.lastBody, "system.0.cache_control.type").String()) + require.NotContains(t, string(input), "site-rule-content") + }) + t.Run("grok_early_responses_route", func(t *testing.T) { + account := businessSystemPromptAPIKeyAccount(true) + account.Platform = PlatformGrok + input := []byte(`{"model":"grok-4.5","stream":false,"prompt_cache_key":"client-seed","instructions":"client","input":[{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + upstream := &httpUpstreamRecorder{resp: promptSendHTTPResponse(200, `{"id":"resp_grok","object":"response","status":"completed","model":"grok-4.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)} + gateway := &OpenAIGatewayService{cfg: businessSystemPromptTestConfig(), httpUpstream: upstream, businessPromptService: promptSendPolicyForPlatform(PlatformGrok, "developer", "conversation_tail")} + _, err := gateway.forwardGrokResponses(context.Background(), c, account, input, "grok-4.5", false, time.Now()) + require.NoError(t, err) + require.Len(t, upstream.bodies, 1) + require.Equal(t, "site-rule-content", gjson.GetBytes(upstream.lastBody, "input.1.content").String()) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.1.role").String()) + require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) + require.NotContains(t, string(input), "site-rule-content") + }) +} + +func TestPromptSendStrictChatRejectsDeveloperWithoutChangingRole(t *testing.T) { + account := businessSystemPromptAPIKeyAccount(false) + body := []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}]}`) + c, recorder := newBusinessSystemPromptGinContext("/v1/chat/completions", body) + upstream := businessSystemPromptErrorUpstream() + policy := newPromptRulesGatewayPolicy("developer", "conversation_head") + gateway := &OpenAIGatewayService{cfg: businessSystemPromptTestConfig(), httpUpstream: upstream, businessPromptService: policy} + _, err := gateway.sendCCUpstreamRequest(context.Background(), c, account, "https://api.deepseek.com/v1/chat/completions", body, false, "fixture", "", "") + require.ErrorIs(t, err, ErrPromptDeliveryUnsupported) + require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) + require.Empty(t, upstream.requests) + responses, application, err := policy.ApplyForSend(c, account, []byte(`{"model":"deepseek-chat","input":"hello"}`), "responses", false) + require.NoError(t, err) + require.True(t, application.Applied) + require.Equal(t, "developer", gjson.GetBytes(responses, "input.0.role").String()) +} + +func TestPromptSendResponsesRetryKeepsCleanSource(t *testing.T) { + input := []byte(`{"model":"gpt-5.4","max_output_tokens":32,"stream":false,"prompt_cache_key":"client-seed","instructions":"client","input":[{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + promptSendHTTPResponse(400, `{"error":{"type":"invalid_request_error","code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`), + promptSendHTTPResponse(200, `{"id":"resp_retry","object":"response","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`), + }} + gateway := &OpenAIGatewayService{cfg: businessSystemPromptTestConfig(), httpUpstream: upstream, businessPromptService: newPromptRulesGatewayPolicy("developer", "conversation_head")} + _, err := gateway.Forward(context.Background(), c, businessSystemPromptAPIKeyAccount(true), input) + require.NoError(t, err) + require.Len(t, upstream.bodies, 2) + for _, wire := range upstream.bodies { + require.Equal(t, 1, strings.Count(string(wire), "site-rule-content")) + require.Equal(t, "hello", gjson.GetBytes(wire, "input.1.content").String()) + } + require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists()) + require.Equal(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String(), gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()) + require.NotContains(t, string(input), "site-rule-content") +} + +func TestPromptSendReasoningRecoveryProjectsOnlyCipherEdits(t *testing.T) { + policy := newPromptRulesGatewayPolicy("developer", "conversation_head") + input := []byte(`{"model":"gpt-5.4","input":[{"type":"reasoning","id":"rs_1","encrypted_content":"cipher-one"},{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + wire, _, err := policy.ApplyForSend(c, businessSystemPromptAPIKeyAccount(true), input, "responses", false) + require.NoError(t, err) + retry, err := sjson.DeleteBytes(wire, "input.1.encrypted_content") + require.NoError(t, err) + clean, err := projectReasoningCipherEdits(input, wire, retry) + require.NoError(t, err) + require.False(t, gjson.GetBytes(clean, "input.0.encrypted_content").Exists()) + require.Equal(t, "rs_1", gjson.GetBytes(clean, "input.0.id").String()) + require.NotContains(t, string(clean), "site-rule-content") + next, _, err := policy.ApplyForSend(c, businessSystemPromptAPIKeyAccount(true), clean, "responses", false) + require.NoError(t, err) + require.JSONEq(t, string(retry), string(next)) + changed, err := sjson.SetBytes(retry, "input.2.content", "other") + require.NoError(t, err) + _, err = projectReasoningCipherEdits(input, wire, changed) + require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) +} + +func TestPromptSendRejectedFieldUsesCustomerIndex(t *testing.T) { + policy := newPromptRulesGatewayPolicy("developer", "conversation_head") + input := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"assistant","status":"completed","content":"earlier"},{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + _, _, err := policy.ApplyForSend(c, businessSystemPromptAPIKeyAccount(true), input, "responses", false) + require.NoError(t, err) + rejection := []byte(`{"error":{"code":"unsupported_parameter","param":"input[1].status","message":"Unsupported parameter: input[1].status"}}`) + clean, _, changed, err := normalizeBusinessPromptRejectedFieldRetryBody(c, 400, input, rejection) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(clean, "input.0.status").Exists()) + require.Equal(t, "hello", gjson.GetBytes(clean, "input.1.content").String()) + rejection = []byte(`{"error":{"code":"unsupported_parameter","param":"input[0].status","message":"Unsupported parameter: input[0].status"}}`) + _, _, changed, err = normalizeBusinessPromptRejectedFieldRetryBody(c, 400, input, rejection) + require.NoError(t, err) + require.False(t, changed, "a server prompt rejection cannot remove a customer field") +} + +func TestPromptFirstWSTurnRemainsFrozenAcrossAdapterAttempts(t *testing.T) { + policy := newPromptRulesGatewayPolicy("developer", "conversation_tail") + account := businessSystemPromptAPIKeyAccount(true) + input := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":"hello"}]}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + beginBusinessSystemPromptFirstWSTurn(c) + first, app, err := policy.ApplyForSend(c, account, input, "responses", false) + require.NoError(t, err) + next := promptRulesSnapshotForTest(BusinessSystemPromptSnapshot{Revision: 9, Enabled: true, Body: "new-revision"}) + policy.snapshot.Store(&next) + beginBusinessSystemPromptFirstWSTurn(c) + retry, frozen, err := policy.ApplyForSend(c, account, input, "responses", false) + require.NoError(t, err) + require.Equal(t, first, retry) + require.Equal(t, app.Revision, frozen.Revision) + beginBusinessSystemPromptRequestTurn(c) + _, newTurn, err := policy.ApplyForSend(c, account, input, "responses", false) + require.NoError(t, err) + require.Equal(t, int64(9), newTurn.Revision) +} + +func TestPromptStructuredEchoKeepsPublicClaudeBlocks(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, "anthropic", []string{"system", "system"}, []string{"after_last_user", "after_last_user"}, []string{"public-site", "private-site"}) + snapshot.ResolvedRules[0].PreserveEcho = true + input := []byte(`{"model":"claude-opus-4-8","messages":[{"role":"assistant","content":"earlier"},{"role":"user","content":"hello"}]}`) + target := BusinessSystemPromptTarget{Platform: "anthropic", Protocol: "messages", UpstreamModel: "claude-opus-4-8"} + wire, app, err := ApplyBusinessSystemPromptToJSON(input, snapshot, target) + require.NoError(t, err) + require.True(t, app.Applied) + c, _ := newBusinessSystemPromptGinContext("/v1/messages", input) + businessSystemPromptRequestSet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, "messages"), cacheBusinessSystemPromptState(input, wire, snapshot, target, app)) + echo := rewritePromptRulesStructuredEcho(c, wire, "messages") + require.Contains(t, string(echo), "public-site") + require.NotContains(t, string(echo), "private-site") + require.Equal(t, "earlier", gjson.GetBytes(echo, "messages.0.content").String()) + require.Equal(t, "hello", gjson.GetBytes(echo, "messages.1.content").String()) + require.Len(t, gjson.GetBytes(echo, "messages.2.content").Array(), 1) +} + +func TestPromptEchoRedactsNestedInstructionCarrierOnly(t *testing.T) { + policy := newPromptRulesGatewayPolicy("auto", "control_append") + input := []byte(`{"model":"gpt-5.4","instructions":"client","input":"hello"}`) + c, _ := newBusinessSystemPromptGinContext("/v1/responses", input) + wire, _, err := policy.ApplyForSend(c, businessSystemPromptAPIKeyAccount(true), input, "responses", false) + require.NoError(t, err) + echo, err := sjson.SetRawBytes([]byte(`{"text":"site-rule-content"}`), "error.body.request", wire) + require.NoError(t, err) + gateway := &OpenAIGatewayService{businessPromptService: policy} + redacted := gateway.rewriteBusinessSystemPromptJSONForAnyRequest(c, echo) + require.Equal(t, "client", gjson.GetBytes(redacted, "error.body.request.instructions").String()) + require.Equal(t, "site-rule-content", gjson.GetBytes(redacted, "text").String()) +} diff --git a/backend/internal/service/business_system_prompt_service.go b/backend/internal/service/business_system_prompt_service.go index 21f99d0055f1..7086a7ea70d6 100644 --- a/backend/internal/service/business_system_prompt_service.go +++ b/backend/internal/service/business_system_prompt_service.go @@ -149,13 +149,14 @@ type BusinessSystemPromptRevisionBus interface { } type BusinessSystemPromptService struct { - previewConfig *config.Config - accountRepo AccountRepository - store BusinessSystemPromptStore - bus BusinessSystemPromptRevisionBus - registry *RemoteSkillRegistryService - registryBus RemoteSkillRegistryRevisionBus - source BusinessSystemPromptSource + previewConfig *config.Config + previewSettings *SettingService + accountRepo AccountRepository + store BusinessSystemPromptStore + bus BusinessSystemPromptRevisionBus + registry *RemoteSkillRegistryService + registryBus RemoteSkillRegistryRevisionBus + source BusinessSystemPromptSource snapshot atomic.Pointer[BusinessSystemPromptSnapshot] stateMu sync.Mutex @@ -359,9 +360,6 @@ func (s *BusinessSystemPromptService) CurrentSnapshot() (BusinessSystemPromptSna if s == nil { return BusinessSystemPromptSnapshot{}, false } - if errors.Is(promptPolicyAvailability(context.Background()), ErrExtensionOperationDisabled) { - return BusinessSystemPromptSnapshot{}, true - } current := s.snapshot.Load() if current == nil { return BusinessSystemPromptSnapshot{}, false @@ -390,7 +388,7 @@ func (s *BusinessSystemPromptService) Reload(ctx context.Context) error { return s.retainLastGood(err) } s.stateMu.Lock() - if current := s.snapshot.Load(); current == nil || loaded.Revision >= current.Revision { + if current := s.snapshot.Load(); current == nil || promptSnapshotNotOlder(loaded, *current) { s.snapshot.Store(&loaded) } s.stateMu.Unlock() @@ -508,27 +506,34 @@ func (s *BusinessSystemPromptService) compileBusinessSystemPromptSnapshot( if s == nil || s.registry == nil { return BusinessSystemPromptSnapshot{}, fmt.Errorf("%w: paired registry unavailable", ErrBusinessSystemPromptUnavailable) } - publication, err := s.registry.ActivePublication(context.Background()) - if err != nil { - return BusinessSystemPromptSnapshot{}, err - } - snapshot.Body = publication.EffectivePromptBody - snapshot.RegistryRevision = publication.Revision - snapshot.RegistryRawTreeSHA256 = publication.Version.RawTreeSHA256 - snapshot.RegistryEffectiveTreeSHA256 = publication.Version.EffectiveTreeSHA256 - snapshot.RegistryPromptRawSHA256 = publication.Prompt.RawSHA256 - snapshot.RegistryPromptEffectiveSHA256 = publication.Prompt.EffectiveSHA256 - snapshot.RegistryUpstreamSourceID = publication.Version.UpstreamSourceID - snapshot.RegistryUpstreamRoot = publication.Version.UpstreamRoot - snapshot.RegistryPublicRoot = publication.Version.PublicRoot - snapshot.BaseSHA256 = publication.Prompt.RawSHA256 - snapshot.EffectiveSHA256 = publication.Prompt.EffectiveSHA256 - snapshot.EffectiveByteLength = len([]byte(publication.EffectivePromptBody)) - return snapshot, nil + return compilePromptPublication(snapshot, s.registry.publication.Load()) } return BusinessSystemPromptSnapshot{}, fmt.Errorf("%w: unsupported composition", ErrBusinessSystemPromptUnavailable) } +func compilePromptPublication(snapshot BusinessSystemPromptSnapshot, publication *RemoteSkillPublication) (BusinessSystemPromptSnapshot, error) { + if publication == nil { + return BusinessSystemPromptSnapshot{}, fmt.Errorf("%w: no paired publication", ErrBusinessSystemPromptUnavailable) + } + snapshot.Body = publication.EffectivePromptBody + snapshot.RegistryRevision = publication.Revision + snapshot.RegistryRawTreeSHA256 = publication.Version.RawTreeSHA256 + snapshot.RegistryEffectiveTreeSHA256 = publication.Version.EffectiveTreeSHA256 + snapshot.RegistryPromptRawSHA256 = publication.Prompt.RawSHA256 + snapshot.RegistryPromptEffectiveSHA256 = publication.Prompt.EffectiveSHA256 + snapshot.RegistryUpstreamSourceID = publication.Version.UpstreamSourceID + snapshot.RegistryUpstreamRoot = publication.Version.UpstreamRoot + snapshot.RegistryPublicRoot = publication.Version.PublicRoot + snapshot.BaseSHA256 = publication.Prompt.RawSHA256 + snapshot.EffectiveSHA256 = publication.Prompt.EffectiveSHA256 + snapshot.EffectiveByteLength = len(publication.EffectivePromptBody) + return snapshot, nil +} + +func promptSnapshotNotOlder(next, current BusinessSystemPromptSnapshot) bool { + return next.Revision > current.Revision || (next.Revision == current.Revision && next.RegistryRevision >= current.RegistryRevision) +} + func (s *BusinessSystemPromptService) retainLastGood(err error) error { s.stateMu.Lock() defer s.stateMu.Unlock() @@ -637,6 +642,9 @@ func (s *BusinessSystemPromptService) PublishVersionAction(ctx context.Context, if s == nil || s.store == nil { return BusinessSystemPromptSnapshot{}, errors.New("business system prompt store unavailable") } + if snapshot, ok := s.CurrentSnapshot(); ok && snapshot.RulePolicy != nil { + return BusinessSystemPromptSnapshot{}, fmt.Errorf("%w: select target rule IDs instead of a global active template", ErrBusinessSystemPromptInvalid) + } if err := s.validateBusinessSystemPromptPublishTarget(ctx, templateID, versionID, expectedRevision, action); err != nil { return BusinessSystemPromptSnapshot{}, err } @@ -767,7 +775,7 @@ func (s *BusinessSystemPromptService) validateBusinessSystemPromptPublishTarget( func (s *BusinessSystemPromptService) installBusinessSystemPromptSnapshot(snapshot BusinessSystemPromptSnapshot) { s.stateMu.Lock() defer s.stateMu.Unlock() - if current := s.snapshot.Load(); current != nil && current.Revision > snapshot.Revision { + if current := s.snapshot.Load(); current != nil && !promptSnapshotNotOlder(snapshot, *current) { return } s.snapshot.Store(&snapshot) diff --git a/backend/internal/service/business_system_prompt_test.go b/backend/internal/service/business_system_prompt_test.go index 80d01ef58731..45f81211290e 100644 --- a/backend/internal/service/business_system_prompt_test.go +++ b/backend/internal/service/business_system_prompt_test.go @@ -10,11 +10,49 @@ import ( "strings" "testing" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) +func businessSystemPromptStoreWithV2Rules(t *testing.T, snapshot BusinessSystemPromptSnapshot) *fakeBusinessSystemPromptStore { + t.Helper() + if snapshot.CompositionMode == "" { + snapshot.CompositionMode = BusinessSystemPromptCompositionInline + } + if snapshot.TemplateVersion == 0 { + snapshot.TemplateVersion = 1 + } + snapshot = promptRulesSnapshotForTest(snapshot) + hash, length, err := ValidateBusinessSystemPromptBody(snapshot.Body) + require.NoError(t, err) + snapshot.SHA256, snapshot.ByteLength = hash, length + return &fakeBusinessSystemPromptStore{ + loaded: snapshot, + detail: BusinessSystemPromptTemplateDetail{ + Template: BusinessSystemPromptTemplate{ID: snapshot.TemplateID}, + Versions: []BusinessSystemPromptVersion{{ + ID: snapshot.VersionID, TemplateID: snapshot.TemplateID, Version: snapshot.TemplateVersion, + Body: snapshot.Body, SHA256: hash, ByteLength: length, + CompositionMode: snapshot.CompositionMode, BundleID: snapshot.BundleID, + BundleManifestSHA256: snapshot.BundleManifestSHA256, + }}, + }, + } +} + +func businessSystemPromptResponseApplication(t *testing.T, input string) BusinessSystemPromptApplication { + t.Helper() + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) + _, application, err := ApplyBusinessSystemPromptToJSON([]byte(input), snapshot, BusinessSystemPromptTarget{ + Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses, + }) + require.NoError(t, err) + require.True(t, application.Applied) + return application +} + func TestBusinessSystemPromptSeedRestoresOriginalBehaviorAndRoutingContract(t *testing.T) { seed := embeddedBusinessSystemPrompt capture, err := buildRemoteSkillPromptCapture([]byte(seed)) @@ -76,11 +114,7 @@ func TestBusinessSystemPromptSeedPreservesOriginalRemoteEntryOrder(t *testing.T) } func TestBusinessSystemPromptSeedBodyIsInjectedByteForByte(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{ - Enabled: true, Body: embeddedBusinessSystemPrompt, Revision: 1, - CompositionMode: BusinessSystemPromptCompositionCodexSkillHybrid, - BundleID: BusinessSystemPromptRemoteSkillBundleID, - } + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{embeddedBusinessSystemPrompt}) body, application, err := ApplyBusinessSystemPromptToJSON( []byte(`{"model":"gpt-5","input":"hello"}`), snapshot, @@ -89,7 +123,8 @@ func TestBusinessSystemPromptSeedBodyIsInjectedByteForByte(t *testing.T) { require.NoError(t, err) require.True(t, application.Applied) require.Equal(t, embeddedBusinessSystemPrompt, gjson.GetBytes(body, "instructions").String()) - require.Equal(t, embeddedBusinessSystemPrompt, application.ServerInstructions) + require.Len(t, application.RulesPlan.Placements, 1) + require.Equal(t, embeddedBusinessSystemPrompt, application.RulesPlan.Placements[0].Body) } func TestValidateBusinessSystemPromptBodyPreservesWhitespace(t *testing.T) { @@ -131,7 +166,7 @@ func TestMergeBusinessSystemPromptInstructions(t *testing.T) { } func TestApplyBusinessSystemPromptToResponsesUsesNativeInstructions(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{Enabled: true, Body: "server", Revision: 7} + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) body, application, err := ApplyBusinessSystemPromptToJSON( []byte(`{"model":"gpt-5","instructions":" client ","input":[{"role":"user","content":"hi"}]}`), snapshot, @@ -139,13 +174,15 @@ func TestApplyBusinessSystemPromptToResponsesUsesNativeInstructions(t *testing.T ) require.NoError(t, err) require.True(t, application.Applied) - require.Equal(t, "client\n\nserver", gjson.GetBytes(body, "instructions").String()) - require.Equal(t, "client", application.ClientInstructions) - require.Equal(t, "server", application.ServerInstructions) + require.Equal(t, " client \n\nserver", gjson.GetBytes(body, "instructions").String()) + require.Equal(t, " client ", application.ClientInstructions) + require.Len(t, application.RulesPlan.Placements, 1) + require.Equal(t, "instructions", application.RulesPlan.Placements[0].Carrier) + require.Equal(t, "server", application.RulesPlan.Placements[0].Body) } func TestApplyBusinessSystemPromptToChatInsertsSystemAfterExistingControlMessages(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{Enabled: true, Body: "server"} + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) body, application, err := ApplyBusinessSystemPromptToJSON( []byte(`{"model":"gpt-5","messages":[{"role":"system","content":"old"},{"role":"developer","content":"dev"},{"role":"user","content":"hi"}]}`), snapshot, @@ -167,29 +204,29 @@ func TestApplyBusinessSystemPromptToChatInsertsSystemAfterExistingControlMessage } func TestBusinessSystemPromptScopeAndCompactSwitch(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{Enabled: true, Body: "server"} - for name, target := range map[string]BusinessSystemPromptTarget{ - "grok excluded": {Platform: PlatformGrok, Protocol: BusinessSystemPromptProtocolResponses}, - "compact off": {Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses, Compact: true}, + for _, tc := range []struct { + name, reason string + target BusinessSystemPromptTarget + }{ + {"outside explicit platform scope", "platform_scope", BusinessSystemPromptTarget{Platform: PlatformGrok, Protocol: BusinessSystemPromptProtocolResponses}}, + {"compact off", "compact_disabled", BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses, Compact: true}}, } { - t.Run(name, func(t *testing.T) { - if target.Compact { - snapshot.CompactEnabled = false - } + t.Run(tc.name, func(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) body := []byte(`{"model":"gpt-5","instructions":"client"}`) - got, application, err := ApplyBusinessSystemPromptToJSON(body, snapshot, target) + got, application, err := ApplyBusinessSystemPromptToJSON(body, snapshot, tc.target) require.NoError(t, err) require.False(t, application.Applied) require.True(t, bytes.Equal(body, got)) + require.Equal(t, []extensionv1.PromptRuleDecision{{RuleID: "rule-0", Reason: tc.reason}}, application.RulesPlan.Skipped) }) } } -func TestDisabledBusinessSystemPromptReturnsSnapshotMetadataWithoutChangingBody(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{ - Enabled: false, ExposeServerPrompt: true, CompactEnabled: true, - TemplateID: 2, VersionID: 3, TemplateVersion: 4, Revision: 5, SHA256: "ABCDEF", - } +func TestDisabledBusinessSystemPromptReturnsRulePlanMetadataWithoutChangingBody(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) + snapshot.Enabled, snapshot.ExposeServerPrompt, snapshot.CompactEnabled = false, true, true + snapshot.Revision = 5 body := []byte(`{"instructions":"client"}`) got, application, err := ApplyBusinessSystemPromptToJSON(body, snapshot, BusinessSystemPromptTarget{ Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses, @@ -198,17 +235,15 @@ func TestDisabledBusinessSystemPromptReturnsSnapshotMetadataWithoutChangingBody( require.Equal(t, body, got) require.False(t, application.Applied) require.Equal(t, int64(5), application.Revision) - require.Equal(t, "abcdef", application.SHA256) require.True(t, application.ExposeServerPrompt) + require.True(t, application.CompactEnabled) + require.Empty(t, application.RulesPlan.Placements) + require.Equal(t, []extensionv1.PromptRuleDecision{{RuleID: "rule-0", Reason: "global_disabled"}}, application.RulesPlan.Skipped) + require.Equal(t, application.RulesPlan.SHA256, application.SHA256) } func TestRewriteBusinessSystemPromptResponseJSONRestoresClientInstructions(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ClientInstructions: "client", - ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{"instructions":"client"}`) body := []byte(`{"id":"resp_1","instructions":"client\n\nserver","output":[]}`) rewritten, err := RewriteBusinessSystemPromptResponseJSON(body, application, false) require.NoError(t, err) @@ -216,11 +251,7 @@ func TestRewriteBusinessSystemPromptResponseJSONRestoresClientInstructions(t *te } func TestRewriteBusinessSystemPromptResponseJSONDeletesServerOnlyInstructions(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{}`) body := []byte(`{"type":"response.completed","response":{"id":"resp_1","instructions":"server"}}`) rewritten, err := RewriteBusinessSystemPromptResponseJSON(body, application, false) require.NoError(t, err) @@ -228,10 +259,7 @@ func TestRewriteBusinessSystemPromptResponseJSONDeletesServerOnlyInstructions(t } func TestRewriteBusinessSystemPromptResponseJSONRestoresStructuredErrorInstructions(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, Carrier: BusinessSystemPromptCarrierInstructions, - ClientInstructions: "client", ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{"instructions":"client"}`) body := []byte(`{"error":{"response":{"instructions":"client\n\nserver"},"message":"unchanged"}}`) rewritten, err := RewriteBusinessSystemPromptResponseJSON(body, application, false) require.NoError(t, err) @@ -240,11 +268,7 @@ func TestRewriteBusinessSystemPromptResponseJSONRestoresStructuredErrorInstructi } func TestRewriteBusinessSystemPromptResponseJSONDoesNotScrubUnexpectedText(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{}`) body := []byte(`{"instructions":"upstream changed this","output":[{"content":[{"text":"server"}]}]}`) rewritten, err := RewriteBusinessSystemPromptResponseJSON(body, application, false) require.NoError(t, err) @@ -252,12 +276,7 @@ func TestRewriteBusinessSystemPromptResponseJSONDoesNotScrubUnexpectedText(t *te } func TestRewriteBusinessSystemPromptSSEPreservesFraming(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ClientInstructions: "client", - ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{"instructions":"client"}`) input := []byte("event: response.created\ndata: \t{\"type\":\"response.created\",\"response\":{\"instructions\":\"client\\n\\nserver\"}}\n\ndata: \t[DONE] \n\n") want := "event: response.created\ndata: \t{\"type\":\"response.created\",\"response\":{\"instructions\":\"client\"}}\n\ndata: \t[DONE] \n\n" rewritten, err := RewriteBusinessSystemPromptSSE(input, application, false) @@ -266,11 +285,7 @@ func TestRewriteBusinessSystemPromptSSEPreservesFraming(t *testing.T) { } func TestRewriteBusinessSystemPromptResponseHonorsExposeSwitch(t *testing.T) { - application := BusinessSystemPromptApplication{ - Applied: true, - Carrier: BusinessSystemPromptCarrierInstructions, - ServerInstructions: "server", - } + application := businessSystemPromptResponseApplication(t, `{}`) body := []byte(`{"instructions":"server"}`) rewritten, err := RewriteBusinessSystemPromptResponseJSON(body, application, true) require.NoError(t, err) @@ -278,7 +293,9 @@ func TestRewriteBusinessSystemPromptResponseHonorsExposeSwitch(t *testing.T) { } func TestRewriteBusinessSystemPromptResponsePreservesPairedPublicationEcho(t *testing.T) { - _, application, err := ApplyBusinessSystemPromptToJSON([]byte(`{"instructions":"client"}`), BusinessSystemPromptSnapshot{Enabled: true, Body: "server", CompositionMode: BusinessSystemPromptCompositionCodexSkillHybrid}, BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}) + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) + snapshot.ResolvedRules[0].PreserveEcho = true + _, application, err := ApplyBusinessSystemPromptToJSON([]byte(`{"instructions":"client"}`), snapshot, BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}) require.NoError(t, err) require.True(t, application.PreserveInstructionsEcho) jsonBody := []byte(`{"id":"resp_1","instructions":"client\n\nserver","output":[]}`) @@ -293,12 +310,8 @@ func TestRewriteBusinessSystemPromptResponsePreservesPairedPublicationEcho(t *te } func TestBusinessSystemPromptApplicationCapturesExposeDecision(t *testing.T) { - snapshot := BusinessSystemPromptSnapshot{ - Enabled: true, - ExposeServerPrompt: true, - Body: "server", - Revision: 8, - } + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"server"}) + snapshot.ExposeServerPrompt, snapshot.Revision = true, 8 _, application, err := ApplyBusinessSystemPromptToJSON( []byte(`{"instructions":"client"}`), snapshot, @@ -332,9 +345,9 @@ func TestBusinessSystemPromptCacheKeyIncludesAppliedRevisionAndHash(t *testing.T } func TestBusinessSystemPromptRequestDecisionIsFrozenAcrossRetry(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ Revision: 1, Enabled: false, Body: "server", - }} + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -349,7 +362,8 @@ func TestBusinessSystemPromptRequestDecisionIsFrozenAcrossRetry(t *testing.T) { require.False(t, application.Applied) require.Equal(t, "client", gjson.GetBytes(first, "instructions").String()) - store.loaded = BusinessSystemPromptSnapshot{Revision: 2, Enabled: true, Body: "new-server"} + next := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{Revision: 2, VersionID: 2, Enabled: true, Body: "new-server"}) + store.loaded, store.detail = next.loaded, next.detail require.NoError(t, policy.Reload(context.Background())) retry, retryApplication, err := gateway.applyBusinessSystemPromptForRequest( c, []byte(`{"instructions":"changed-client"}`), account, BusinessSystemPromptProtocolResponses, false, @@ -368,10 +382,10 @@ func TestBusinessSystemPromptRequestDecisionIsFrozenAcrossRetry(t *testing.T) { require.Equal(t, "client\n\nnew-server", gjson.GetBytes(fresh, "instructions").String()) } -func TestBusinessSystemPromptRequestDoesNotDuplicateAfterCacheRewrite(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ +func TestBusinessSystemPromptCleanRetryDoesNotDuplicateAfterCacheRewrite(t *testing.T) { + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ Revision: 9, Enabled: true, Body: "server", - }} + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -379,27 +393,32 @@ func TestBusinessSystemPromptRequestDoesNotDuplicateAfterCacheRewrite(t *testing gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) account := &Account{Platform: PlatformOpenAI} + clean := []byte(`{"instructions":"client","prompt_cache_key":"key"}`) + original := bytes.Clone(clean) body, application, err := gateway.applyBusinessSystemPromptForRequest( - c, []byte(`{"instructions":"client","prompt_cache_key":"key"}`), account, BusinessSystemPromptProtocolResponses, false, + c, clean, account, BusinessSystemPromptProtocolResponses, false, ) require.NoError(t, err) body, err = rewriteBusinessSystemPromptCacheKey(c, body, application) require.NoError(t, err) retry, retryApplication, err := gateway.applyBusinessSystemPromptForRequest( - c, body, account, BusinessSystemPromptProtocolResponses, false, + c, clean, account, BusinessSystemPromptProtocolResponses, false, ) require.NoError(t, err) retry, err = rewriteBusinessSystemPromptCacheKey(c, retry, retryApplication) require.NoError(t, err) + require.Equal(t, original, clean, "the caller retains clean bytes for retries") require.Equal(t, "client\n\nserver", gjson.GetBytes(retry, "instructions").String()) + require.Equal(t, 1, strings.Count(gjson.GetBytes(retry, "instructions").String(), "server")) + require.Equal(t, application.RulesPlan.SHA256, retryApplication.RulesPlan.SHA256) require.Equal(t, gjson.GetBytes(body, "prompt_cache_key").String(), gjson.GetBytes(retry, "prompt_cache_key").String()) } func TestBusinessSystemPromptRetryPreservesRawSnapshotWhitespace(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ Revision: 4, Enabled: true, Body: " server ", - }} + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -416,14 +435,15 @@ func TestBusinessSystemPromptRetryPreservesRawSnapshotWhitespace(t *testing.T) { c, []byte(`{"instructions":"changed-client"}`), account, BusinessSystemPromptProtocolResponses, false, ) require.NoError(t, err) - require.Equal(t, "changed-client\n\nserver", gjson.GetBytes(retry, "instructions").String()) + require.Equal(t, "changed-client\n\n server ", gjson.GetBytes(retry, "instructions").String()) + require.Equal(t, " server ", application.RulesPlan.Placements[0].Body) require.Equal(t, int64(4), application.Revision) } func TestBusinessSystemPromptSnapshotIsFrozenAcrossAdapterFallback(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ Revision: 4, Enabled: true, Body: "old-server", - }} + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -437,33 +457,38 @@ func TestBusinessSystemPromptSnapshotIsFrozenAcrossAdapterFallback(t *testing.T) require.NoError(t, err) require.Equal(t, int64(4), firstApplication.Revision) - store.loaded = BusinessSystemPromptSnapshot{Revision: 5, Enabled: true, Body: "new-server"} + next := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{Revision: 5, VersionID: 2, Enabled: true, Body: "new-server"}) + store.loaded, store.detail = next.loaded, next.detail require.NoError(t, policy.Reload(context.Background())) fallback, fallbackApplication, err := gateway.applyBusinessSystemPromptForRequest( c, []byte(`{"messages":[{"role":"user","content":"hello"}]}`), account, BusinessSystemPromptProtocolChat, false, ) require.NoError(t, err) require.Equal(t, int64(4), fallbackApplication.Revision) + require.Equal(t, firstApplication.RulesPlan.Placements[0].VersionID, fallbackApplication.RulesPlan.Placements[0].VersionID) require.True(t, chatBodyHasSystemPrompt(fallback, "old-server")) require.NotContains(t, string(fallback), "new-server") } func TestBusinessSystemPromptResponseUsesRequestScopedExposeDecision(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ - Revision: 1, Enabled: true, ExposeServerPrompt: true, Body: "server", - }} + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ + Revision: 1, Enabled: true, ExposeServerPrompt: false, Body: "server", + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set(businessSystemPromptRequestApplicationKey+":"+BusinessSystemPromptProtocolResponses, businessSystemPromptRequestState{ - application: BusinessSystemPromptApplication{ - Applied: true, Carrier: BusinessSystemPromptCarrierInstructions, - ServerInstructions: "server", ExposeServerPrompt: false, - }, - }) + _, application, err := policy.ApplyForSend(c, &Account{Platform: PlatformOpenAI}, []byte(`{"input":"hello"}`), BusinessSystemPromptProtocolResponses, false) + require.NoError(t, err) + require.True(t, application.Applied) + require.False(t, application.ExposeServerPrompt) + store.loaded.Revision, store.loaded.ExposeServerPrompt = 2, true + require.NoError(t, policy.Reload(context.Background())) + published, ok := policy.CurrentSnapshot() + require.True(t, ok) + require.True(t, published.ExposeServerPrompt) rewritten := gateway.rewriteBusinessSystemPromptJSONForRequest( c, @@ -473,8 +498,8 @@ func TestBusinessSystemPromptResponseUsesRequestScopedExposeDecision(t *testing. require.False(t, gjson.GetBytes(rewritten, "response.instructions").Exists()) } -func TestOpenAIGatewayBusinessSystemPromptHelperScopesAndMerges(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "server"}} +func TestOpenAIGatewayBusinessSystemPromptHelperHonorsExplicitScopeAndMerges(t *testing.T) { + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "server"}) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -488,12 +513,13 @@ func TestOpenAIGatewayBusinessSystemPromptHelperScopesAndMerges(t *testing.T) { require.NoError(t, err) require.False(t, app.Applied) require.JSONEq(t, `{"instructions":"client"}`, string(grokBody)) + require.Equal(t, []extensionv1.PromptRuleDecision{{RuleID: "fixture", Reason: "platform_scope"}}, app.RulesPlan.Skipped) } -func TestBusinessSystemPromptRequestStateNeverCrossesIntoGrok(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{ +func TestBusinessSystemPromptRequestStateHonorsSelectedPlatformScope(t *testing.T) { + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{ Revision: 1, Enabled: true, Body: "server", - }} + }) policy := NewBusinessSystemPromptService(store, nil) require.NoError(t, policy.Initialize(context.Background())) gateway := &OpenAIGatewayService{businessPromptService: policy} @@ -520,4 +546,5 @@ func TestBusinessSystemPromptRequestStateNeverCrossesIntoGrok(t *testing.T) { require.NoError(t, err) require.False(t, grokApplication.Applied) require.JSONEq(t, `{"instructions":"grok-client"}`, string(grokBody)) + require.Equal(t, []extensionv1.PromptRuleDecision{{RuleID: "fixture", Reason: "platform_scope"}}, grokApplication.RulesPlan.Skipped) } diff --git a/backend/internal/service/business_system_prompt_turn_cache.go b/backend/internal/service/business_system_prompt_turn_cache.go index 5986fed20fe5..bd2063e90243 100644 --- a/backend/internal/service/business_system_prompt_turn_cache.go +++ b/backend/internal/service/business_system_prompt_turn_cache.go @@ -8,10 +8,26 @@ import ( const businessSystemPromptTurnCacheKey = "openai_business_system_prompt_turn_cache" +const businessSystemPromptFirstWSTurnKey = "business_system_prompt_first_ws_turn_started" + +// Re-entering a WS adapter after an initial dial/account failure still belongs +// to the first client frame. Only an actually received next frame starts a new +// snapshot; transport attempts must not observe a just-published revision. +func beginBusinessSystemPromptFirstWSTurn(c *gin.Context) { + if c == nil { + return + } + if started, _ := c.Get(businessSystemPromptFirstWSTurnKey); started == true { + return + } + beginBusinessSystemPromptRequestTurn(c) + c.Set(businessSystemPromptFirstWSTurnKey, true) +} + // A WS connection owns only its current prompt cache. The ingress parser starts // a new turn after the previous relay/AfterTurn has finished; accounting has // already captured its own metadata and never reads these prompt cache keys. -// Replacing the pointer releases the completed turn's snapshots and full body +// Replacing the pointer releases the completed turn's snapshots and echo proofs // instead of keeping one set of Gin keys for every turn in the connection. type businessSystemPromptTurnCache struct { mu sync.RWMutex @@ -49,18 +65,3 @@ func businessSystemPromptRequestSet(ctx *gin.Context, key string, value any) { } ctx.Set(key, value) } - -func businessSystemPromptRequestDelete(ctx *gin.Context, key string) { - if ctx == nil { - return - } - current, _ := ctx.Get(businessSystemPromptTurnCacheKey) - if cache, ok := current.(*businessSystemPromptTurnCache); ok && cache != nil { - cache.mu.Lock() - delete(cache.values, key) - cache.mu.Unlock() - return - } - // HTTP callers never need this operation: it is reserved for the native - // WS accumulator's proven clean, service-owned payloads. -} diff --git a/backend/internal/service/business_system_prompt_turn_cache_test.go b/backend/internal/service/business_system_prompt_turn_cache_test.go index 4a101bbb79ef..02770637c3a4 100644 --- a/backend/internal/service/business_system_prompt_turn_cache_test.go +++ b/backend/internal/service/business_system_prompt_turn_cache_test.go @@ -20,8 +20,8 @@ func TestBusinessSystemPromptWSTurnCacheDoesNotRetainCompletedBodies(t *testing. account := businessSystemPromptAPIKeyAccount(true) keyCount := 0 for turn := int64(1); turn <= 4; turn++ { - store.loaded = BusinessSystemPromptSnapshot{Enabled: true, Revision: turn, Body: fmt.Sprintf("server-%d", turn)} - require.NoError(t, policy.Reload(context.Background())) + store.loaded = promptRulesSnapshotForTest(BusinessSystemPromptSnapshot{Enabled: true, Revision: turn, Body: fmt.Sprintf("server-%d", turn)}) + policy.snapshot.Store(&store.loaded) beginBusinessSystemPromptRequestTurn(c) _, applied := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses) require.False(t, applied, "a new turn must not inherit a completed response application") @@ -34,12 +34,14 @@ func TestBusinessSystemPromptWSTurnCacheDoesNotRetainCompletedBodies(t *testing. require.NoError(t, err) require.Equal(t, body, retried) require.Equal(t, application, repeated) + rewritten := gateway.rewriteBusinessSystemPromptJSONForRequest(c, body, BusinessSystemPromptProtocolResponses) + require.Equal(t, gjson.GetBytes(original, "instructions").String(), gjson.GetBytes(rewritten, "instructions").String()) chat, fallback, err := gateway.applyBusinessSystemPromptForRequest(c, []byte(`{"messages":[{"role":"user","content":"fallback"}]}`), account, BusinessSystemPromptProtocolChat, false) require.NoError(t, err) require.Equal(t, turn, fallback.Revision) require.True(t, chatBodyHasSystemPrompt(chat, store.loaded.Body)) - rewritten := gateway.rewriteBusinessSystemPromptJSONForRequest(c, body, BusinessSystemPromptProtocolResponses) - require.Equal(t, gjson.GetBytes(original, "instructions").String(), gjson.GetBytes(rewritten, "instructions").String()) + _, staleApplication := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses) + require.False(t, staleApplication, "the fallback must not expose the superseded protocol's application") wire := deriveBusinessSystemPromptCacheKey(c, "seed", application) require.Equal(t, wire, deriveBusinessSystemPromptCacheKey(c, wire, application)) keys := c.Copy().Keys diff --git a/backend/internal/service/business_system_prompt_v2_fixture_test.go b/backend/internal/service/business_system_prompt_v2_fixture_test.go new file mode 100644 index 000000000000..0f39285e7bcc --- /dev/null +++ b/backend/internal/service/business_system_prompt_v2_fixture_test.go @@ -0,0 +1,30 @@ +package service + +import ( + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// Old storage fixtures still describe an immutable version. Gateway tests make +// its explicit migrated rule before exercising the v2-only send lifecycle. +func promptRulesSnapshotForTest(snapshot BusinessSystemPromptSnapshot) BusinessSystemPromptSnapshot { + if snapshot.RulePolicy != nil { + return snapshot + } + if snapshot.TemplateID == 0 { + snapshot.TemplateID = 1 + } + if snapshot.VersionID == 0 { + snapshot.VersionID = 1 + } + rule := extensionv1.PromptRule{ID: "fixture", Name: "Fixture prompt", Enabled: true, + TemplateID: snapshot.TemplateID, VersionID: snapshot.VersionID, + Role: extensionv1.PromptRoleAuto, Position: extensionv1.PromptPositionControlAppend, + Platforms: []string{PlatformOpenAI, PlatformCindy}, ModelMatch: "upstream", Models: []string{}} + snapshot.RulePolicy = &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{rule.ID}} + if snapshot.Body != "" { + hash, _, _ := extensionv1.ValidateTextDocument(snapshot.Body, extensionv1.PromptRulesMaxBytes) + snapshot.ResolvedRules = []extensionv1.ResolvedPromptRule{{Rule: rule, Body: snapshot.Body, SHA256: hash, + PreserveEcho: snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid}} + } + return snapshot +} diff --git a/backend/internal/service/business_system_prompt_ws_requested_model_test.go b/backend/internal/service/business_system_prompt_ws_requested_model_test.go new file mode 100644 index 000000000000..3bebbd7c2464 --- /dev/null +++ b/backend/internal/service/business_system_prompt_ws_requested_model_test.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestPromptWSNativeContinuationRetainsRequestedModelAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := newOpenAIWSExecutionScopeTestConfig() + snapshot := unifiedPromptSnapshot(t, PlatformOpenAI, []string{"auto"}, []string{"control_append"}, []string{"alias-scoped-prompt"}) + snapshot.RulePolicy.Rules[0].ModelMatch = "requested" + snapshot.RulePolicy.Rules[0].Models = []string{"client-model"} + snapshot.ResolvedRules[0].Rule = snapshot.RulePolicy.Rules[0] + policy := NewBusinessSystemPromptService(nil, nil) + policy.snapshot.Store(&snapshot) + + captureConn := &openAIWSCaptureConn{ + writeSignals: make(chan struct{}, 2), + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_alias_1","model":"gpt-5.1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_alias_2","model":"gpt-5.1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + defer pool.Close() + gateway := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + businessPromptService: policy, + } + account := &Account{ + ID: 116, Name: "prompt-requested-model", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "model_mapping": map[string]any{"client-model": "gpt-5.1"}, + }, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + serverErrCh := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + serverErrCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = r.Clone(r.Context()) + ginCtx.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + serverErrCh <- err + return + } + serverErrCh <- gateway.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil) + })) + defer server.Close() + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + client, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = client.CloseNow() }() + + frames := []string{ + `{"type":"response.create","model":"client-model","instructions":"client-first","input":[{"role":"user","content":"first"}]}`, + `{"type":"response.create","previous_response_id":"resp_alias_1","instructions":"client-second","input":[{"role":"user","content":"second"}]}`, + } + for _, frame := range frames { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = client.Write(writeCtx, coderws.MessageText, []byte(frame)) + cancelWrite() + require.NoError(t, err) + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event, readErr := client.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + } + require.NoError(t, client.Close(coderws.StatusNormalClosure, "done")) + select { + case serverErr := <-serverErrCh: + require.NoError(t, serverErr) + case <-time.After(5 * time.Second): + t.Fatal("native websocket ingress did not finish") + } + + require.Len(t, captureConn.writes, 2) + for turn, request := range captureConn.writes { + wire := requestToJSONString(request) + require.Equal(t, "gpt-5.1", gjson.Get(wire, "model").String()) + require.Contains(t, gjson.Get(wire, "instructions").String(), gjson.Get(frames[turn], "instructions").String()) + require.Equal(t, 1, strings.Count(wire, "alias-scoped-prompt"), "turn %d must match the requested alias exactly once", turn+1) + } + require.Equal(t, "resp_alias_1", gjson.Get(requestToJSONString(captureConn.writes[1]), "previous_response_id").String()) + require.Equal(t, 1, captureDialer.DialCount(), "the continuation must use the native websocket connection") +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index cc6446da736c..d91d670f0bc5 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -170,7 +170,6 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( if err := input.Parsed.ReplaceBody(wireBody); err != nil { return nil, err } - input.Body = input.Parsed.Body.Bytes() } resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) @@ -402,6 +401,11 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( token string, ) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) + var promptErr error + body, _, promptErr = s.businessPromptService.ApplyForSend(c, account, body, "messages", false) + if promptErr != nil { + return nil, nil, promptErr + } targetURL := claudeAPIURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -728,7 +732,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( } } - restored := string(reverseToolNamesIfPresent(c, []byte(line))) + restored := string(reverseToolNamesIfPresent(c, rewritePromptRulesStructuredSSE(c, []byte(line), "messages"))) if !bufferCindyPreamble { // Preserve the legacy passthrough behavior for ordinary Anthropic // accounts: forward each line immediately. Only strict Cindy needs @@ -1108,6 +1112,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if contentType == "" { contentType = "application/json" } + body = rewritePromptRulesStructuredEcho(c, body, "messages") body = reverseToolNamesIfPresent(c, body) c.Data(resp.StatusCode, contentType, body) return usage, nil diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 80e2afcfac59..0c512c2232b0 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -187,11 +187,15 @@ func (s *GatewayService) executeBedrockUpstream( var err error retryStart := time.Now() for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + wireBody, _, promptErr := s.businessPromptService.ApplyForSendModel(c, account, body, "messages", false, modelID) + if promptErr != nil { + return nil, promptErr + } var upstreamReq *http.Request if account.IsBedrockAPIKey() { - upstreamReq, err = s.buildUpstreamRequestBedrockAPIKey(ctx, body, modelID, region, stream, apiKey) + upstreamReq, err = s.buildUpstreamRequestBedrockAPIKey(ctx, wireBody, modelID, region, stream, apiKey) } else { - upstreamReq, err = s.buildUpstreamRequestBedrock(ctx, body, modelID, region, stream, signer) + upstreamReq, err = s.buildUpstreamRequestBedrock(ctx, wireBody, modelID, region, stream, signer) } if err != nil { return nil, err @@ -398,6 +402,6 @@ func (s *GatewayService) handleBedrockNonStreamingResponse( if v := resp.Header.Get("x-amzn-requestid"); v != "" { c.Header("x-request-id", v) } - c.Data(resp.StatusCode, "application/json", body) + c.Data(resp.StatusCode, "application/json", rewritePromptRulesStructuredEcho(c, body, "messages")) return usage, nil } diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index fbdeb683eaaa..95f60b5d155d 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -383,14 +383,30 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( systemRaw any, model string, ) []byte { - if account == nil || !account.IsOAuth() || len(body) == 0 { + prepared, err := s.prepareClaudeCodeOAuthMimicryToBody(ctx, c, account, body, systemRaw, model) + if err != nil { return body } + return prepared +} - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - if systemPromptInjectionEnabled { - systemPromptBlocks = claudeOAuthSystemPromptBlocksForModel(model, systemPromptBlocks) - body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) +func (s *GatewayService) prepareClaudeCodeOAuthMimicryToBody( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + systemRaw any, + model string, +) ([]byte, error) { + if account == nil || !account.IsOAuth() || len(body) == 0 { + return body, nil + } + + setBusinessSystemPromptRequestProfile(c, account, true) + var err error + body, err = s.prepareClaudeOAuthSystemBase(ctx, c, account, body, systemRaw, model) + if err != nil { + return nil, err } normalizeOpts := claudeOAuthNormalizeOptions{} @@ -428,7 +444,28 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( body = applyToolsLastCacheBreakpoint(body) } - return body + return body, nil +} + +// prepareClaudeOAuthSystemBase owns only required provider preparation. Custom +// text is selected from the frozen unified rule snapshot and inserted later by +// ApplyForSend; the former settings payload is no longer an injection source. +func (s *GatewayService) prepareClaudeOAuthSystemBase(ctx context.Context, c *gin.Context, account *Account, body []byte, systemRaw any, model string) ([]byte, error) { + enabled, _, _ := s.claudeOAuthSystemPromptInjectionSettings(ctx) + if !enabled { + return body, nil + } + structured, err := s.businessPromptService.HasAnthropicSystemBlocksForSend(c, account, body, model) + if err != nil { + return nil, err + } + blocks := claudeOAuthSystemPromptBlocksForModel(model, "") + if structured { + // A nonempty configuration with no emitted text yields an empty system + // array while preserving the same client-system relocation routine. + blocks = `[{"type":"text","text":""}]` + } + return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), "", blocks), nil } // buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体, @@ -693,7 +730,34 @@ type claudeOAuthSystemPromptBlockConfig struct { } type claudeOAuthSystemPromptBlocksEnvelope struct { - Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` + Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` + ExpansionPrompt string `json:"expansion_prompt,omitempty"` +} + +// ExpandClaudeOAuthSystemPromptBlocks compiles a structured immutable content +// source against the final Messages request. Expansion text is substituted in +// one pass, preserving the former nonrecursive placeholder semantics. +func ExpandClaudeOAuthSystemPromptBlocks(body []byte, configured string) (json.RawMessage, error) { + expansion := "" + if strings.HasPrefix(strings.TrimSpace(configured), "{") { + var envelope claudeOAuthSystemPromptBlocksEnvelope + if err := json.Unmarshal([]byte(configured), &envelope); err != nil { + return nil, err + } + expansion = envelope.ExpansionPrompt + } + blocks, err := buildClaudeOAuthSystemPromptBlocksJSON(body, expansion, configured) + if err != nil { + return nil, err + } + packed, err := sjson.SetRawBytes([]byte(`{}`), "system", buildJSONArrayRaw(blocks)) + if err != nil { + return nil, err + } + // The old base layout was normalized before sending. Apply that same + // narrow identity normalization to this migrated source before hashing it. + packed, _ = normalizeClaudeOAuthSystemBody(packed) + return json.RawMessage(gjson.GetBytes(packed, "system").Raw), nil } // claudeFableOAuthSystemPromptBlocks keeps the Claude Code identity required by diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 2561cc18bf94..df4bc3ef5746 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -24,6 +24,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return fmt.Errorf("parse request: empty request") } + rememberPromptRequestedModel(c, parsed.Body.Bytes()) + setBusinessSystemPromptRequestProfile(c, account, false) validationModel := parsed.Model if account != nil && account.Type == AccountTypeAPIKey { validationModel = account.GetMappedModel(validationModel) @@ -67,10 +69,25 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return err } - isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + isClaudeCodeCT := IsClaudeCodeClient(ctx) + if c != nil { + isClaudeCodeCT = isClaudeCodeCT || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + } + if !isClaudeCodeCT && parsed.MetadataUserID != "" { + isClaudeCodeCT = systemHasBillingAttributionBlock(body) + } shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT + setBusinessSystemPromptRequestProfile(c, account, shouldMimicClaudeCode) if shouldMimicClaudeCode { + systemRaw, _ := parsed.SystemValue() + preparedBase, err := s.prepareClaudeOAuthSystemBase(ctx, c, account, body, systemRaw, claude.NormalizeModelID(reqModel)) + if err != nil { + return err + } + if err := replaceBody(preparedBase); err != nil { + return err + } var normalizedBody []byte normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, claudeOAuthNormalizeOptions{}) if err := replaceBody(normalizedBody); err != nil { @@ -258,7 +275,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 透传成功响应 - c.Data(resp.StatusCode, "application/json", respBody) + c.Data(resp.StatusCode, "application/json", rewritePromptRulesStructuredEcho(c, respBody, "messages")) return nil } @@ -408,7 +425,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex if contentType == "" { contentType = "application/json" } - c.Data(resp.StatusCode, contentType, respBody) + c.Data(resp.StatusCode, contentType, rewritePromptRulesStructuredEcho(c, respBody, "messages")) return nil } @@ -420,6 +437,11 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( token string, ) (*http.Request, error) { body = stripDeferredToolCacheControl(body) + var promptErr error + body, _, promptErr = s.businessPromptService.ApplyForSend(c, account, body, "messages", false) + if promptErr != nil { + return nil, promptErr + } targetURL := claudeAPICountTokensURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -556,6 +578,12 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // 一致性铁律:同一次请求内只取一次 mimic UA,billing cc_version 与出站 // User-Agent 头共用这一个字符串(同 buildUpstreamRequest)。 ctMimicUserAgent := claude.DefaultUserAgent() + businessSystemPromptRequestSet(c, businessSystemPromptBillingUserAgentKey, effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint)) + var promptErr error + body, _, promptErr = s.businessPromptService.ApplyForSendModel(c, account, body, "messages", false, modelID) + if promptErr != nil { + return nil, nil, promptErr + } if billingUA := effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { body = syncBillingHeaderVersion(body, billingUA) } diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index f2927737659f..db3760321996 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -93,6 +93,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + rememberPromptRequestedModel(c, parsed.Body.Bytes()) + setBusinessSystemPromptRequestProfile(c, account, false) // API-key mappings and OAuth native IDs are resolved before mimicry. validationModel := parsed.Model if account != nil && account.Type == AccountTypeAPIKey { @@ -205,6 +207,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + setBusinessSystemPromptRequestProfile(c, account, shouldMimicClaudeCode) if shouldMimicClaudeCode { // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code @@ -213,11 +216,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 systemRaw, _ := parsed.SystemValue() - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - if systemPromptInjectionEnabled { - if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { - return nil, err - } + preparedBase, err := s.prepareClaudeOAuthSystemBase(ctx, c, account, body, systemRaw, claude.NormalizeModelID(reqModel)) + if err != nil { + return nil, err + } + if err := replaceBody(preparedBase); err != nil { + return nil, err } normalizeOpts := claudeOAuthNormalizeOptions{} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 886b70275316..65d027ea0712 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -34,6 +34,8 @@ func (s *GatewayService) ForwardAsChatCompletions( parsed *ParsedRequest, ) (*ForwardResult, error) { startTime := time.Now() + rememberPromptRequestedModel(c, body) + setBusinessSystemPromptRequestProfile(c, account, false) // 1. Parse Chat Completions request var ccReq apicompat.ChatCompletionsRequest @@ -105,7 +107,10 @@ func (s *GatewayService) ForwardAsChatCompletions( shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) + anthropicBody, err = s.prepareClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) + if err != nil { + return nil, err + } } // 7. Enforce cache_control block limit diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 44ed3329c4ff..5368ce40f9f1 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -36,6 +36,8 @@ func (s *GatewayService) ForwardAsResponses( parsed *ParsedRequest, ) (*ForwardResult, error) { startTime := time.Now() + rememberPromptRequestedModel(c, body) + setBusinessSystemPromptRequestProfile(c, account, false) normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) if err != nil { @@ -115,7 +117,10 @@ func (s *GatewayService) ForwardAsResponses( shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) + anthropicBody, err = s.prepareClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) + if err != nil { + return nil, err + } } // 7. Enforce cache_control block limit diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index f43f3959f122..a7e76c13d534 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -818,6 +818,7 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou // GatewayService handles API gateway operations type GatewayService struct { + businessPromptService *BusinessSystemPromptService accountRepo AccountRepository groupRepo GroupRepository usageLogRepo UsageLogRepository diff --git a/backend/internal/service/gateway_system_prompt.go b/backend/internal/service/gateway_system_prompt.go new file mode 100644 index 000000000000..807b7464f494 --- /dev/null +++ b/backend/internal/service/gateway_system_prompt.go @@ -0,0 +1,119 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *GatewayService) SetBusinessSystemPromptService(prompts *BusinessSystemPromptService) { + if s != nil { + s.businessPromptService = prompts + } +} + +func (s *GeminiMessagesCompatService) SetBusinessSystemPromptService(prompts *BusinessSystemPromptService) { + if s != nil { + s.businessPromptService = prompts + } +} + +func (s *AntigravityGatewayService) SetBusinessSystemPromptService(prompts *BusinessSystemPromptService) { + if s != nil { + s.businessPromptService = prompts + } +} + +func rememberPromptRequestedModelName(c *gin.Context, model string) { + if model == "" { + return + } + if _, exists := businessSystemPromptRequestGet(c, promptRequestedModelContextKey); !exists { + businessSystemPromptRequestSet(c, promptRequestedModelContextKey, model) + } +} + +// applyGeminiPromptForSend operates on the final Gemini request, including the +// Code Assist/Antigravity envelope. Provider metadata stays in the envelope; +// only request.systemInstruction belongs to the prompt engine. +func applyGeminiPromptForSend(prompts *BusinessSystemPromptService, c *gin.Context, account *Account, body []byte, model string) ([]byte, error) { + for _, field := range []string{"request", "generateContentRequest"} { + inner := gjson.GetBytes(body, field) + if inner.IsObject() { + if envelopeModel := gjson.GetBytes(body, "model").String(); envelopeModel != "" { + model = envelopeModel + } + updated, _, err := prompts.ApplyForSendModel(c, account, []byte(inner.Raw), "gemini", false, model) + if err != nil { + return nil, err + } + if bytes.Equal(updated, []byte(inner.Raw)) { + return body, nil + } + return sjson.SetRawBytes(body, field, updated) + } + } + updated, _, err := prompts.ApplyForSendModel(c, account, body, "gemini", false, model) + return updated, err +} + +// applyGeminiPromptToHTTPRequest receives a freshly built request on every +// attempt. Its body is never assigned back to the clean conversion input. +func applyGeminiPromptToHTTPRequest(prompts *BusinessSystemPromptService, c *gin.Context, account *Account, req *http.Request, model string) ([]byte, error) { + if req == nil || req.Body == nil { + return nil, fmt.Errorf("empty Gemini upstream request") + } + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + updated, err := applyGeminiPromptForSend(prompts, c, account, body, model) + if err != nil { + return nil, err + } + // AI Studio countTokens accepts system instructions only inside its + // generateContentRequest. Vertex countTokens uses the native top-level + // systemInstruction schema, identified by its final publisher URL. + if req.URL != nil && strings.HasSuffix(req.URL.Path, ":countTokens") && !strings.Contains(req.URL.Path, "/publishers/") && + !gjson.GetBytes(updated, "generateContentRequest").Exists() && gjson.GetBytes(updated, "systemInstruction").Exists() { + modelResource := model + if !strings.HasPrefix(modelResource, "models/") { + modelResource = "models/" + modelResource + } + inner, err := sjson.SetBytes(updated, "model", modelResource) + if err != nil { + return nil, err + } + updated, err = json.Marshal(map[string]json.RawMessage{"generateContentRequest": inner}) + if err != nil { + return nil, err + } + } + req.Body = io.NopCloser(bytes.NewReader(updated)) + req.ContentLength = int64(len(updated)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(updated)), nil + } + return updated, nil +} + +func (s *AntigravityGatewayService) buildPromptedAPIRequest(ctx context.Context, c *gin.Context, account *Account, baseURL, action, token string, body []byte) (*http.Request, error) { + updated, err := applyGeminiPromptForSend(s.businessPromptService, c, account, body, gjson.GetBytes(body, "model").String()) + if err != nil { + return nil, err + } + if baseURL == "" { + return antigravity.NewAPIRequest(ctx, action, token, updated) + } + return antigravity.NewAPIRequestWithURL(ctx, baseURL, action, token, updated) +} diff --git a/backend/internal/service/gateway_system_prompt_finalize.go b/backend/internal/service/gateway_system_prompt_finalize.go new file mode 100644 index 000000000000..7e0f8e896c26 --- /dev/null +++ b/backend/internal/service/gateway_system_prompt_finalize.go @@ -0,0 +1,71 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +const businessSystemPromptBillingUserAgentKey = "business_system_prompt_billing_user_agent" + +// FinalizePromptMessageApplication applies the provider's cache and billing +// preparation after placement. The actual emitted structured blocks, including +// cache attributes, become the plan's content and digest. Preview uses the same +// function with a simulated billing user agent. +func FinalizePromptMessageApplication(body []byte, application BusinessSystemPromptApplication, account *Account, settings *SettingService, billingUserAgent string, c *gin.Context) ([]byte, BusinessSystemPromptApplication, error) { + if account == nil || !account.IsAnthropicOAuthOrSetupToken() { + return body, application, nil + } + final := enforceCacheControlLimit(body) + if settings != nil && settings.IsAnthropicCacheTTL1hInjectionEnabled(promptPolicyRequestContext(c)) { + final = injectAnthropicCacheControlTTL1h(final) + } + if billingUserAgent != "" { + final = syncBillingHeaderVersion(final, billingUserAgent) + } + if bytes.Equal(body, final) || application.RulesPlan == nil || !application.Applied { + return final, application, nil + } + plan := *application.RulesPlan + plan.Placements = append([]extensionv1.PromptRulePlacement(nil), plan.Placements...) + application.RulesPlan = &plan + blocks := gjson.GetBytes(final, "system").Array() + for index := range plan.Placements { + placement := &plan.Placements[index] + if placement.Carrier != "system" || placement.Index == nil { + continue + } + first := *placement.Index + count := 1 + if placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + count = len(gjson.ParseBytes(placement.StructuredContent).Array()) + } + if first < 0 || first+count > len(blocks) { + return nil, application, fmt.Errorf("%w: final structured prompt bounds", ErrBusinessSystemPromptInvalid) + } + if placement.ContentFormat == extensionv1.PromptContentAnthropicSystemBlocks { + emitted := make([]json.RawMessage, 0, count) + for _, block := range blocks[first : first+count] { + emitted = append(emitted, json.RawMessage(block.Raw)) + } + raw, err := json.Marshal(emitted) + if err != nil { + return nil, application, err + } + placement.Body, placement.StructuredContent = string(raw), raw + } else { + placement.Body = blocks[first].Get("text").String() + } + hash, _, err := validateBusinessSystemPromptBodyWithLimit(placement.Body, extensionv1.PromptRulesMaxBytes) + if err != nil { + return nil, application, err + } + placement.SHA256 = hash + } + return final, promptpolicy.FinishRulesPlan(application), nil +} diff --git a/backend/internal/service/gateway_system_prompt_preview.go b/backend/internal/service/gateway_system_prompt_preview.go new file mode 100644 index 000000000000..e4b810e9ce14 --- /dev/null +++ b/backend/internal/service/gateway_system_prompt_preview.go @@ -0,0 +1,201 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// ResolvePromptGatewayPreviewModel shares each provider's live model selection +// before body conversion, so upstream-scoped rules see the real destination. +func ResolvePromptGatewayPreviewModel(account *Account, ingress string, body []byte, requested string) (string, error) { + if account == nil { + return "", ErrBusinessSystemPromptInvalid + } + if account.IsBedrock() { + mapped, ok := ResolveBedrockModelID(account, requested) + if !ok { + return "", ErrPromptDeliveryUnsupported + } + return mapped, nil + } + if account.Platform == PlatformAntigravity { + if account.Type == AccountTypeUpstream { + return requested, nil + } + adapter := &AntigravityGatewayService{} + if ingress == "gemini" { + mapped := adapter.getMappedModelForThinkingLevel(account, requested, geminiThinkingLevelFromBody(body)) + if mapped == "" { + return "", ErrPromptDeliveryUnsupported + } + return mapped, nil + } + anthropicBody, err := promptPreviewAnthropicInput(ingress, body, requested) + if err != nil { + return "", err + } + var request antigravity.ClaudeRequest + if err := json.Unmarshal(anthropicBody, &request); err != nil { + return "", err + } + mapped := adapter.getMappedModelForThinkingLevel(account, requested, geminiThinkingLevelFromClaudeThinking(request.Thinking)) + if mapped == "" { + return "", ErrPromptDeliveryUnsupported + } + thinking := request.Thinking != nil && (request.Thinking.Type == "enabled" || request.Thinking.Type == "adaptive") + return applyThinkingModelSuffix(mapped, thinking), nil + } + if account.Type == AccountTypeAPIKey { + return account.GetMappedModel(requested), nil + } + if account.Type == AccountTypeServiceAccount { + if mapped, matched := account.ResolveMappedModel(requested); matched { + return mapped, nil + } + if account.Platform == PlatformAnthropic { + return normalizeVertexAnthropicModelID(claude.NormalizeModelID(requested)), nil + } + } + if account.Platform == PlatformAnthropic { + return claude.NormalizeModelID(requested), nil + } + return requested, nil +} + +// PreparePromptGatewayPreview uses the same body converters and required +// provider preparation as the live non-OpenAI gateways. It performs no token, +// credential, fingerprint, or upstream access. OAuth identity and envelope +// values are consequently simulated. The caller installs its draft snapshot +// on c before calling and applies the unified prompt engine to the result. +func PreparePromptGatewayPreview(ctx context.Context, c *gin.Context, account *Account, ingress string, body []byte, model string, settings *SettingService, prompts *BusinessSystemPromptService) (clean []byte, protocol, base, profile string, err error) { + if account == nil { + return nil, "", "", "", ErrBusinessSystemPromptInvalid + } + rememberPromptRequestedModel(c, body) + if ingress != "gemini" { + body, err = promptPreviewAnthropicInput(ingress, body, model) + if err != nil { + return nil, "", "", "", err + } + } + if account.Platform == PlatformGemini { + if ingress != "gemini" { + body, err = convertClaudeMessagesToGeminiGenerateContent(body) + if err != nil { + return nil, "", "", "", err + } + } else if filtered, filterErr := filterEmptyPartsFromGeminiRequest(body); filterErr == nil { + body = filtered + } + body = ensureGeminiFunctionCallThoughtSignatures(body) + if ingress != "gemini" && (account.Type != AccountTypeOAuth || account.GetCredential("project_id") == "") { + body = normalizeGeminiRequestForAIStudio(body) + } + return body, "gemini", "", "", nil + } + if account.Platform == PlatformAntigravity && account.Type != AccountTypeUpstream { + adapter := &AntigravityGatewayService{settingService: settings} + if ingress == "gemini" { + body, err = injectIdentityPatchToGeminiRequest(body) + if err != nil { + return nil, "", "", "", err + } + if cleaned, cleanErr := cleanGeminiRequest(body); cleanErr == nil { + body = cleaned + } + if reconciled, reconcileErr := enableMixedGeminiToolInvocations(body); reconcileErr == nil { + body = reconciled + } + return body, "gemini", antigravity.GetDefaultIdentityPatch(), "", nil + } + var request antigravity.ClaudeRequest + if err := json.Unmarshal(body, &request); err != nil { + return nil, "", "", "", err + } + opts := adapter.getClaudeTransformOptions(ctx) + opts.EnableIdentityPatch = true + wrapped, err := antigravity.TransformClaudeToGeminiWithOptions(&request, "preview", model, opts) + if err != nil { + return nil, "", "", "", err + } + identity := opts.IdentityPatch + if identity == "" { + identity = antigravity.GetDefaultIdentityPatch() + } + return []byte(gjson.GetBytes(wrapped, "request").Raw), "gemini", identity, "", nil + } + if ingress == "gemini" { + return nil, "", "", "", ErrPromptDeliveryUnsupported + } + adapter := &GatewayService{settingService: settings, businessPromptService: prompts} + mimic := account.IsAnthropicOAuthOrSetupToken() + if mimic && c != nil && c.Request != nil { + metadata := gjson.GetBytes(body, "metadata.user_id").String() + mimic = !IsClaudeCodeClient(ctx) && !isClaudeCodeClient(c.GetHeader("User-Agent"), metadata) && !(metadata != "" && systemHasBillingAttributionBlock(body)) + } + setBusinessSystemPromptRequestProfile(c, account, mimic) + if mimic { + body, err = adapter.prepareClaudeCodeOAuthMimicryToBody(ctx, c, account, body, json.RawMessage(gjson.GetBytes(body, "system").Raw), model) + if err != nil { + return nil, "", "", "", err + } + profile = "generic-mimic" + base = gjson.GetBytes(body, "system").Raw + } else if account.IsAnthropicOAuthOrSetupToken() { + profile = "claude-code" + } + if next, changed := adapter.normalizeClientDatelineIfEnabled(ctx, account, body); changed { + body = next + } + body = enforceCacheControlLimit(body) + body = FilterThinkingBlocks(FilterWebSearchHistoryBlocks(StripEmptyTextBlocks(body), model), model) + if ResolveThinkingProtocol(model) == ThinkingProtocolPassbackRequired { + if normalized, changed := NormalizeChineseLLMThinking(body, model); changed { + body = normalized + } + } + return body, "messages", base, profile, nil +} + +func promptPreviewAnthropicInput(ingress string, body []byte, model string) ([]byte, error) { + if ingress == "messages" { + return ReplaceModelInBody(body, model), nil + } + var request *apicompat.ResponsesRequest + switch ingress { + case "chat": + var chat apicompat.ChatCompletionsRequest + if err := json.Unmarshal(body, &chat); err != nil { + return nil, err + } + converted, err := apicompat.ChatCompletionsToResponses(&chat) + if err != nil { + return nil, err + } + request = converted + case "responses": + adapted, _, err := adaptResponsesClientToolsForAnthropic(body) + if err != nil { + return nil, err + } + request = &apicompat.ResponsesRequest{} + if err := json.Unmarshal(adapted, request); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("%w: unknown ingress protocol", ErrBusinessSystemPromptInvalid) + } + request.Model = model + converted, err := apicompat.ResponsesToAnthropicRequest(request) + if err != nil { + return nil, err + } + return json.Marshal(converted) +} diff --git a/backend/internal/service/gateway_system_prompt_test.go b/backend/internal/service/gateway_system_prompt_test.go new file mode 100644 index 000000000000..4f0fa3385b7c --- /dev/null +++ b/backend/internal/service/gateway_system_prompt_test.go @@ -0,0 +1,356 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func promptV2GatewayFixture(t *testing.T, platform, content, format string) (*BusinessSystemPromptService, *gin.Context, extensionv1.PromptRule) { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + rule := extensionv1.PromptRule{ID: "prompt", Name: "Prompt", Enabled: true, TemplateID: 1, VersionID: 2, Order: 100, Role: "auto", Platforms: []string{platform}, Position: "control_append", ModelMatch: "upstream", Models: []string{}} + if format == extensionv1.PromptContentAnthropicSystemBlocks { + rule.AccountTypes = []string{AccountTypeOAuth, AccountTypeSetupToken} + rule.RequestProfiles = []string{"generic-mimic"} + rule.ExcludeModelContains = []string{"fable"} + } + hash, _, err := extensionv1.ValidateTextDocument(content, extensionv1.PromptRulesMaxBytes) + require.NoError(t, err) + resolved := extensionv1.ResolvedPromptRule{Rule: rule, Body: content, SHA256: hash, ContentFormat: format} + if format != "" { + resolved.StructuredContent = json.RawMessage(content) + } + snapshot := BusinessSystemPromptSnapshot{Enabled: true, Revision: 9, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{rule.ID}}, ResolvedRules: []extensionv1.ResolvedPromptRule{resolved}} + prompts := NewBusinessSystemPromptService(nil, nil) + prompts.snapshot.Store(&snapshot) + businessSystemPromptRequestSet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestSnapshotKey, ""), snapshot) + return prompts, c, rule +} + +func TestPromptV2GatewayMessagesFinalBuilders(t *testing.T) { + for _, path := range []string{"native", "passthrough", "count", "passthrough-count"} { + t.Run(path, func(t *testing.T) { + prompts, c, _ := promptV2GatewayFixture(t, PlatformAnthropic, "site-prompt-v2", "") + svc := &GatewayService{businessPromptService: prompts, cfg: &config.Config{}} + account := &Account{ID: 11, Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + clean := []byte(`{"model":"claude-sonnet-4-6","system":[{"type":"text","text":"client","cache_control":{"type":"ephemeral","ttl":"1h"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"fixture"}}]}]}`) + for range 2 { + var req *http.Request + var err error + switch path { + case "native": + req, _, err = svc.buildUpstreamRequest(context.Background(), c, account, clean, "fixture", "apikey", "claude-sonnet-4-6", false, false) + case "passthrough": + req, _, err = svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, clean, "fixture") + case "count": + req, _, err = svc.buildCountTokensRequest(context.Background(), c, account, clean, "fixture", "apikey", "claude-sonnet-4-6", false) + default: + req, err = svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, clean, "fixture") + } + require.NoError(t, err) + wire, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.NoError(t, req.Body.Close()) + require.Equal(t, 1, strings.Count(string(wire), "site-prompt-v2")) + require.Equal(t, "client", gjson.GetBytes(wire, "system.0.text").String()) + require.Equal(t, "1h", gjson.GetBytes(wire, "system.0.cache_control.ttl").String()) + require.Equal(t, "site-prompt-v2", gjson.GetBytes(wire, "system.1.text").String()) + require.Equal(t, "image", gjson.GetBytes(wire, "messages.0.content.1.type").String()) + } + require.NotContains(t, string(clean), "site-prompt-v2") + }) + } +} + +type promptV2CaptureUpstream struct{ bodies [][]byte } + +func (u *promptV2CaptureUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + u.bodies = append(u.bodies, body) + return nil, errors.New("offline capture complete") +} + +func (u *promptV2CaptureUpstream) DoWithTLS(req *http.Request, proxy string, account int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxy, account, concurrency) +} + +func TestPromptV2GatewayGeminiFinalRequests(t *testing.T) { + for _, ingress := range []string{"messages", "chat", "gemini", "countTokens"} { + t.Run(ingress, func(t *testing.T) { + prompts, c, _ := promptV2GatewayFixture(t, PlatformGemini, "site-prompt-v2", "") + upstream := &promptV2CaptureUpstream{} + svc := &GeminiMessagesCompatService{businessPromptService: prompts, cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 12, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture"}} + body := []byte(`{"model":"gemini-2.5-flash","system":"client","messages":[{"role":"user","content":"hello"}],"max_tokens":32}`) + var err error + switch ingress { + case "messages": + _, err = svc.Forward(context.Background(), c, account, body) + case "chat": + body = []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"system","content":"client"},{"role":"user","content":"hello"}]}`) + _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body) + default: + body = []byte(`{"systemInstruction":{"parts":[{"text":"client"}]},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) + action := "generateContent" + if ingress == "countTokens" { + action = "countTokens" + } + _, err = svc.ForwardNative(context.Background(), c, account, "gemini-2.5-flash", action, false, body) + } + if ingress != "countTokens" { + require.Error(t, err) + } + require.Len(t, upstream.bodies, 1) + wire := upstream.bodies[0] + require.Equal(t, 1, strings.Count(string(wire), "site-prompt-v2")) + if ingress == "countTokens" { + require.False(t, gjson.GetBytes(wire, "contents").Exists()) + require.False(t, gjson.GetBytes(wire, "systemInstruction").Exists()) + require.Equal(t, "models/gemini-2.5-flash", gjson.GetBytes(wire, "generateContentRequest.model").String()) + require.Greater(t, estimateGeminiCountTokens(wire), estimateGeminiCountTokens(body)) + wire = []byte(gjson.GetBytes(wire, "generateContentRequest").Raw) + } + require.Equal(t, "site-prompt-v2", gjson.GetBytes(wire, "systemInstruction.parts.1.text").String()) + require.Equal(t, "hello", gjson.GetBytes(wire, "contents.0.parts.0.text").String()) + require.NotContains(t, string(body), "site-prompt-v2") + }) + } +} + +func TestPromptV2GatewayGeminiEnvelopesKeepCleanRetrySource(t *testing.T) { + for _, platform := range []string{PlatformGemini, PlatformAntigravity} { + t.Run(platform, func(t *testing.T) { + prompts, c, _ := promptV2GatewayFixture(t, platform, "site-prompt-v2", "") + account := &Account{ID: 13, Platform: platform, Type: AccountTypeOAuth} + clean := []byte(`{"model":"gemini-2.5-flash","project":"fixture","request":{"systemInstruction":{"parts":[{"text":"base identity"}]},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}}`) + svc := &AntigravityGatewayService{businessPromptService: prompts} + for range 2 { + var req *http.Request + var err error + if platform == PlatformAntigravity { + req, err = svc.buildPromptedAPIRequest(context.Background(), c, account, "", "streamGenerateContent", "fixture", clean) + } else { + req, err = http.NewRequest(http.MethodPost, "https://example.test/v1internal:generateContent", bytes.NewReader(clean)) + require.NoError(t, err) + _, err = applyGeminiPromptToHTTPRequest(prompts, c, account, req, "gemini-2.5-flash") + } + require.NoError(t, err) + wire, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.NoError(t, req.Body.Close()) + require.Equal(t, 1, strings.Count(string(wire), "site-prompt-v2")) + require.False(t, gjson.GetBytes(wire, "systemInstruction").Exists()) + require.Equal(t, "site-prompt-v2", gjson.GetBytes(wire, "request.systemInstruction.parts.1.text").String()) + require.Equal(t, "fixture", gjson.GetBytes(wire, "project").String()) + } + require.NotContains(t, string(clean), "site-prompt-v2") + }) + } +} + +type promptV2BindingRepository struct { + AccountRepository + account *Account + calls int +} + +func (r *promptV2BindingRepository) GetByID(context.Context, int64) (*Account, error) { + r.calls++ + return r.account, nil +} + +func TestPromptV2GatewayClaudeStructuredSourceUsesOneAttempt(t *testing.T) { + content := `{"blocks":[{"text":"{billing_header}"},{"text":"custom {fp} {claude_code_expansion_prompt}","cache_control":{"type":"ephemeral","ttl":"1h"}},{"text":"{claude_code_system_prompt}"},{"text":"disabled","enabled":false}],"expansion_prompt":"literal {cc_version}"}` + prompts, c, _ := promptV2GatewayFixture(t, PlatformAnthropic, content, extensionv1.PromptContentAnthropicSystemBlocks) + account := &Account{ID: 14, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + repo := &promptV2BindingRepository{account: account} + prompts.SetAccountRepository(repo) + svc := &GatewayService{businessPromptService: prompts, cfg: &config.Config{}} + clean := []byte(`{"model":"claude-sonnet-4-6","system":"client instructions","messages":[{"role":"user","content":"real original customer message"}]}`) + base, err := svc.prepareClaudeCodeOAuthMimicryToBody(context.Background(), c, account, clean, "client instructions", "claude-sonnet-4-6") + require.NoError(t, err) + require.Empty(t, gjson.GetBytes(base, "system").Array()) + require.Contains(t, gjson.GetBytes(base, "messages.0.content.0.text").String(), "client instructions") + // Account changes after base preparation belong to the next attempt. They + // must not remove the structured source after its default base was omitted. + off := *account + off.Extra = map[string]any{PromptAccountBindingExtraKey: extensionv1.PromptAccountBinding{Mode: "off"}} + repo.account = &off + _, wire, err := svc.buildUpstreamRequest(context.Background(), c, account, base, "fixture", "oauth", "claude-sonnet-4-6", false, true) + require.NoError(t, err) + require.Equal(t, 1, repo.calls) + require.Len(t, gjson.GetBytes(wire, "system").Array(), 3) + require.Equal(t, "custom "+computeClaudeCodeFingerprint(clean, claude.EffectiveCLIVersion())+" literal {cc_version}", gjson.GetBytes(wire, "system.1.text").String()) + require.Equal(t, "1h", gjson.GetBytes(wire, "system.1.cache_control.ttl").String()) + require.Equal(t, claudeCodeSystemPrompt, gjson.GetBytes(wire, "system.2.text").String()) + require.NotContains(t, string(wire), "disabled") + expansionPrefix := strings.SplitN(claudeCodeSystemPromptExpansion, "\n", 2)[0] + for _, block := range gjson.GetBytes(wire, "system").Array() { + require.NotContains(t, block.Get("text").String(), expansionPrefix) + } + // A new attempt rechecks the binding using the original clean client body. + base, err = svc.prepareClaudeCodeOAuthMimicryToBody(context.Background(), c, account, clean, "client instructions", "claude-sonnet-4-6") + require.NoError(t, err) + _, wire, err = svc.buildUpstreamRequest(context.Background(), c, account, base, "fixture", "oauth", "claude-sonnet-4-6", false, true) + require.NoError(t, err) + require.Equal(t, 2, repo.calls) + expansionCount := 0 + for _, block := range gjson.GetBytes(wire, "system").Array() { + text := block.Get("text").String() + if strings.HasPrefix(text, expansionPrefix) { + expansionCount++ + require.Equal(t, claudeCodeSystemPromptExpansion, text) + } + require.NotContains(t, text, "custom ") + } + require.Equal(t, 1, expansionCount, "a new disabled attempt restores the default expansion exactly once") +} + +func TestPromptV2GatewayClaudeStructuredSourceExcludesFable(t *testing.T) { + content := `[{"text":"custom must not apply"}]` + prompts, c, _ := promptV2GatewayFixture(t, PlatformAnthropic, content, extensionv1.PromptContentAnthropicSystemBlocks) + account := &Account{ID: 15, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + svc := &GatewayService{businessPromptService: prompts, cfg: &config.Config{}} + clean := []byte(`{"model":"claude-fable-5","system":"client","messages":[{"role":"user","content":"hello"}]}`) + base, err := svc.prepareClaudeCodeOAuthMimicryToBody(context.Background(), c, account, clean, "client", "claude-fable-5") + require.NoError(t, err) + _, wire, err := svc.buildUpstreamRequest(context.Background(), c, account, base, "fixture", "oauth", "claude-fable-5", false, true) + require.NoError(t, err) + require.Len(t, gjson.GetBytes(wire, "system").Array(), 2) + require.NotContains(t, string(wire), "custom must not apply") + require.NotContains(t, string(wire), claudeCodeSystemPromptExpansion) +} + +func TestPromptV2GatewayFinalCachePreparationMatchesPreviewAndPlan(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + settings := NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{SettingKeyEnableAnthropicCacheTTL1hInjection: "true"}}, &config.Config{}) + content := `[{"text":"{billing_header}"},{"text":"one","cache_control":true},{"text":"two","cache_control":true},{"text":"three","cache_control":true},{"text":"four","cache_control":true},{"text":"five","cache_control":true}]` + prompts, c, _ := promptV2GatewayFixture(t, PlatformAnthropic, content, extensionv1.PromptContentAnthropicSystemBlocks) + prompts.previewSettings = settings + account := &Account{ID: 16, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + svc := &GatewayService{businessPromptService: prompts, settingService: settings, cfg: &config.Config{}} + clean := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hello"}]}`) + base, err := svc.prepareClaudeCodeOAuthMimicryToBody(context.Background(), c, account, clean, nil, "claude-sonnet-4-6") + require.NoError(t, err) + _, wire, err := svc.buildUpstreamRequest(context.Background(), c, account, base, "fixture", "oauth", "claude-sonnet-4-6", false, true) + require.NoError(t, err) + application, ok := businessSystemPromptApplicationFromRequest(c, "messages") + require.True(t, ok) + require.NoError(t, validateBusinessSystemPromptFinal(c, wire, "messages")) + require.Equal(t, "1h", gjson.GetBytes(wire, "system.1.cache_control.ttl").String()) + require.False(t, gjson.GetBytes(wire, "system.5.cache_control").Exists()) + require.JSONEq(t, gjson.GetBytes(wire, "system").Raw, string(application.RulesPlan.Placements[0].StructuredContent)) + + // The preview starts from the same prepared body and the same frozen + // source expansion, then shares the final cache/billing normalization. + snapshot, err := prompts.snapshotForRequest(c) + require.NoError(t, err) + target := promptSendTarget(c, account, base, "messages", false, "claude-sonnet-4-6") + snapshot, target, err = prompts.prepareAnthropicPromptSend(c, account, base, snapshot, target) + require.NoError(t, err) + preview, previewApplication, err := ApplyBusinessSystemPromptToJSONContext(context.Background(), base, snapshot, target) + require.NoError(t, err) + preview, previewApplication, err = FinalizePromptMessageApplication(preview, previewApplication, account, settings, claude.DefaultUserAgent(), c) + require.NoError(t, err) + require.JSONEq(t, gjson.GetBytes(wire, "system").Raw, gjson.GetBytes(preview, "system").Raw) + require.Equal(t, application.RulesPlan.SHA256, previewApplication.RulesPlan.SHA256) +} + +func TestPromptV2GatewayDisabledRulesAvoidAccountRepository(t *testing.T) { + prompts, c, _ := promptV2GatewayFixture(t, PlatformAnthropic, "disabled prompt", "") + snapshot, err := prompts.snapshotForRequest(c) + require.NoError(t, err) + snapshot.Enabled = false + businessSystemPromptRequestSet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestSnapshotKey, ""), snapshot) + repo := &promptV2BindingRepository{} + prompts.SetAccountRepository(repo) + account := &Account{ID: 17, Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + body := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hello"}]}`) + out, application, err := prompts.ApplyForSend(c, account, body, "messages", false) + require.NoError(t, err) + require.False(t, application.Applied) + require.Equal(t, body, out) + require.Zero(t, repo.calls) +} + +func TestPromptV2GatewayActualResponseEchoBoundaries(t *testing.T) { + for _, protocol := range []string{"messages", "gemini"} { + for _, stream := range []bool{false, true} { + t.Run(protocol+map[bool]string{false: "-json", true: "-sse"}[stream], func(t *testing.T) { + platform := PlatformAnthropic + clean := []byte(`{"model":"claude-sonnet-4-6","system":"client","messages":[{"role":"user","content":"hello"}]}`) + carrier := "system" + if protocol == "gemini" { + platform, carrier = PlatformGemini, "systemInstruction" + clean = []byte(`{"systemInstruction":{"parts":[{"text":"client"}]},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) + } + prompts, initialContext, _ := promptV2GatewayFixture(t, platform, "site-prompt-v2", "") + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request, c.Keys = initialContext.Request, initialContext.Keys + account := &Account{ID: 18, Platform: platform, Type: AccountTypeAPIKey} + wire, _, err := prompts.ApplyForSendModel(c, account, clean, protocol, false, "gemini-2.5-flash") + require.NoError(t, err) + response := map[string]any{ + carrier: json.RawMessage(gjson.GetBytes(wire, carrier).Raw), + "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", + "content": []map[string]string{{"type": "text", "text": "site-prompt-v2"}}, + "usage": map[string]int{"input_tokens": 5, "output_tokens": 2}, + } + if protocol == "gemini" { + delete(response, "content") + response["candidates"] = []any{map[string]any{"content": map[string]any{"role": "model", "parts": []any{map[string]string{"text": "site-prompt-v2"}}}, "finishReason": "STOP"}} + } + encoded, err := json.Marshal(response) + require.NoError(t, err) + payload, contentType := string(encoded), "application/json" + if stream { + payload, contentType = "data: "+payload+"\n\n", "text/event-stream" + if protocol == "messages" { + payload += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + } + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(payload))} + if protocol == "messages" { + gateway := &GatewayService{cfg: &config.Config{}} + if stream { + _, err = gateway.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, account, time.Now(), "claude-sonnet-4-6") + } else { + _, err = gateway.handleNonStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, account) + } + } else { + gateway := &GeminiMessagesCompatService{cfg: &config.Config{}} + if stream { + _, err = gateway.handleNativeStreamingResponse(c, resp, time.Now(), false, account, "fixture") + } else { + _, err = gateway.handleNativeNonStreamingResponse(c, resp, false, account, "fixture") + } + } + require.NoError(t, err) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "site-prompt-v2"), "only the model's generated text remains") + require.Contains(t, recorder.Body.String(), "client") + }) + } + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ff2438a615a0..ff06a4deed02 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -21,6 +21,11 @@ import ( func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { + var promptErr error + body, _, promptErr = s.businessPromptService.ApplyForSendModel(c, account, body, "messages", false, modelID) + if promptErr != nil { + return nil, nil, promptErr + } req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) return req, body, err } @@ -88,6 +93,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 请求体 x-anthropic-billing-header 的 cc_version 都源自这一个字符串, // 避免运行期版本缓存翻转瞬间头/体版本自相矛盾(会被判非正版客户端)。 mimicUserAgent := claude.DefaultUserAgent() + businessSystemPromptRequestSet(c, businessSystemPromptBillingUserAgentKey, effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint)) + var promptErr error + body, _, promptErr = s.businessPromptService.ApplyForSendModel(c, account, body, "messages", false, modelID) + if promptErr != nil { + return nil, nil, promptErr + } // Mimicry may override the cached User-Agent later, even without a fingerprint. if billingUA := effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint); billingUA != "" { diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 6aaaaf4b16d5..710741993fbc 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -477,7 +477,7 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res switch resp.StatusCode { case 400: - c.Data(http.StatusBadRequest, "application/json", body) + c.Data(http.StatusBadRequest, "application/json", rewritePromptRulesStructuredEcho(c, body, "messages")) summary := upstreamMsg if summary == "" { summary = truncateForLog(body, 512) @@ -1085,7 +1085,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http for _, block := range outputBlocks { if !clientDisconnected { - restored := reverseToolNamesIfPresent(c, []byte(block)) + restored := reverseToolNamesIfPresent(c, rewritePromptRulesStructuredSSE(c, []byte(block), "messages")) if _, werr := fmt.Fprint(w, string(restored)); werr != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") @@ -1460,6 +1460,7 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h } } + body = rewritePromptRulesStructuredEcho(c, body, "messages") body = reverseToolNamesIfPresent(c, body) // 写入响应 diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index bdc5ddc3ef61..2e4b5ace2fbd 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -38,6 +38,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required") } + rememberPromptRequestedModel(c, body) originalModel := ccReq.Model clientStream := ccReq.Stream includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage @@ -122,6 +123,10 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( } requestIDHeader = idHeader + if _, err := applyGeminiPromptToHTTPRequest(s.businessPromptService, c, account, upstreamReq, mappedModel); err != nil { + return nil, err + } + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { return nil, s.handleUpstreamTransportError(ctx, c, account, err) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 278cbfa6fa53..74ac2348d1aa 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -56,6 +56,7 @@ const ( const geminiDummyThoughtSignature = "skip_thought_signature_validator" type GeminiMessagesCompatService struct { + businessPromptService *BusinessSystemPromptService accountRepo AccountRepository groupRepo GroupRepository cache GatewayCache @@ -633,6 +634,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, fmt.Errorf("missing model") } + rememberPromptRequestedModel(c, body) originalModel := req.Model mappedModel := req.Model if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { @@ -818,6 +820,10 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } requestIDHeader = idHeader + if _, err := applyGeminiPromptToHTTPRequest(s.businessPromptService, c, account, upstreamReq, mappedModel); err != nil { + return nil, err + } + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { return nil, s.handleUpstreamTransportError(ctx, c, account, err) @@ -1159,6 +1165,7 @@ func isGeminiSignatureRelatedError(respBody []byte) bool { } func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) { + rememberPromptRequestedModelName(c, originalModel) beginUpstreamResponseModelObservation(c) beginGeminiImageOutputObservation(c) startTime := time.Now() @@ -1339,6 +1346,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } var resp *http.Response + countBody := body for attempt := 1; attempt <= geminiMaxRetries; attempt++ { upstreamReq, idHeader, err := buildReq(ctx) if err != nil { @@ -1353,13 +1361,18 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } requestIDHeader = idHeader + countBody, err = applyGeminiPromptToHTTPRequest(s.businessPromptService, c, account, upstreamReq, mappedModel) + if err != nil { + return nil, err + } + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { transportErr := s.handleUpstreamTransportError(ctx, c, account, err) // countTokens 不因上游链路故障而失败:本地估算兜底,不换号。 var failoverErr *UpstreamFailoverError if action == "countTokens" && errors.As(transportErr, &failoverErr) { - estimated := estimateGeminiCountTokens(body) + estimated := estimateGeminiCountTokens(countBody) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ RequestID: "", @@ -1430,7 +1443,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. continue } if action == "countTokens" { - estimated := estimateGeminiCountTokens(body) + estimated := estimateGeminiCountTokens(countBody) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ RequestID: "", @@ -1471,7 +1484,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. // This avoids Gemini SDKs failing hard during preflight token counting. // Checked before error policy so it always works regardless of custom error codes. if action == "countTokens" && isOAuth && isGeminiInsufficientScope(resp.Header, respBody) { - estimated := estimateGeminiCountTokens(body) + estimated := estimateGeminiCountTokens(countBody) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ RequestID: requestID, @@ -1618,7 +1631,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. b, _ := json.Marshal(collected) upstreamResponseModelObserverFromContext(c).ObserveGemini(b) observeGeminiImageOutputs(c, b) - c.Data(http.StatusOK, "application/json", b) + c.Data(http.StatusOK, "application/json", rewritePromptRulesStructuredEcho(c, b, "gemini")) usage = usageObj } else { usageResp, err := s.handleNativeNonStreamingResponse(c, resp, isOAuth, account, requestID) @@ -1806,7 +1819,7 @@ func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Cont contentType = "application/json" } MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, respBody) + c.Data(resp.StatusCode, contentType, rewritePromptRulesStructuredEcho(c, respBody, "gemini")) if upstreamMsg == "" { return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) } @@ -2636,6 +2649,9 @@ func isGeminiInsufficientScope(headers http.Header, body []byte) bool { } func estimateGeminiCountTokens(reqBody []byte) int { + if inner := gjson.GetBytes(reqBody, "generateContentRequest"); inner.IsObject() { + reqBody = []byte(inner.Raw) + } total := 0 // systemInstruction.parts[].text @@ -2733,7 +2749,7 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co if contentType == "" { contentType = "application/json" } - c.Data(resp.StatusCode, contentType, respBody) + c.Data(resp.StatusCode, contentType, rewritePromptRulesStructuredEcho(c, respBody, "gemini")) if u := extractGeminiUsage(respBody); u != nil { return u, nil @@ -2791,7 +2807,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) // Keepalive / done markers if payload == "" || payload == "[DONE]" { - _, _ = io.WriteString(c.Writer, line) + _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) flusher.Flush() } else { var rawToWrite string @@ -2825,10 +2841,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte if isOAuth { // SSE format requires double newline (\n\n) to separate events - _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", rawToWrite) + _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", rewritePromptRulesStructuredEcho(c, []byte(rawToWrite), "gemini")) } else { // Pass-through for AI Studio responses. - _, _ = io.WriteString(c.Writer, line) + _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) } flusher.Flush() } @@ -2836,7 +2852,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte if !sawDataEvent { fallback.AddLine(trimmed) } - _, _ = io.WriteString(c.Writer, line) + _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) flusher.Flush() } } diff --git a/backend/internal/service/openai_business_system_prompt.go b/backend/internal/service/openai_business_system_prompt.go index 588dc903247a..1b78ec6042d1 100644 --- a/backend/internal/service/openai_business_system_prompt.go +++ b/backend/internal/service/openai_business_system_prompt.go @@ -1,7 +1,6 @@ package service import ( - "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -17,7 +16,6 @@ import ( const ( businessSystemPromptRequestApplicationKey = "openai_business_system_prompt_application" businessSystemPromptRequestSnapshotKey = "openai_business_system_prompt_snapshot" - businessSystemPromptRequestCompiledKey = "openai_business_system_prompt_compiled_snapshot" businessSystemPromptRequestTurnKey = "openai_business_system_prompt_turn" businessSystemPromptRequestTargetKey = "openai_business_system_prompt_target" businessSystemPromptCacheIdentityKey = "openai_business_system_prompt_cache_identity" @@ -38,31 +36,11 @@ type businessSystemPromptCacheIdentities struct { } type businessSystemPromptRequestState struct { - rulesUndo []promptRulesCarrierUndo - application BusinessSystemPromptApplication - snapshot BusinessSystemPromptSnapshot - target BusinessSystemPromptTarget - inputHash [32]byte - output []byte - undo businessSystemPromptUndo - otherUndo businessSystemPromptUndo - historyUncertain bool -} - -// One output exists per protocol/turn. At most one proof for each of the two -// native carriers is retained; only the instructions proof holds a bounded -// original value. Neither proof stores customer history or another full body. -type businessSystemPromptUndo struct { - present bool - valid bool - restorable bool - carrier string - server string - beforeExists bool - beforeHash [32]byte - afterHash [32]byte - instructions []byte - messageIndex int + rulesUndo []promptRulesCarrierUndo + application BusinessSystemPromptApplication + snapshot BusinessSystemPromptSnapshot + target BusinessSystemPromptTarget + inputHash [32]byte } func businessSystemPromptTargetForAccount(account *Account, protocol string, compact bool) BusinessSystemPromptTarget { @@ -70,6 +48,7 @@ func businessSystemPromptTargetForAccount(account *Account, protocol string, com if account != nil { target.AccountID, target.Platform, target.AccountType = account.ID, account.EffectiveWirePlatform(), account.Type target.ProviderPlatform, target.ProviderProfile = account.Platform, account.EffectiveProviderProfile() + target.ChatSystemRoleOnly = protocol == BusinessSystemPromptProtocolChat && requiresSystemChatRole(account, account.GetOpenAIBaseURL()) if binding := account.Extra[PromptAccountBindingExtraKey]; binding != nil { raw, err := json.Marshal(binding) if err == nil { @@ -88,497 +67,35 @@ func rememberBusinessSystemPromptTarget(ctx *gin.Context, target BusinessSystemP } } -// Ignore only JSON framing whitespace. Hash slices directly so a large message -// history is neither copied nor retained in a second request-cache snapshot. -func businessSystemPromptCarrierHash(raw []byte) [32]byte { - digest := sha256.New() - quoted, escaped, start := false, false, 0 - for index, character := range raw { - if quoted { - if escaped { - escaped = false - } else if character == '\\' { - escaped = true - } else if character == '"' { - quoted = false - } - continue - } - if character == '"' { - quoted = true - continue - } - if character == ' ' || character == '\t' || character == '\r' || character == '\n' { - _, _ = digest.Write(raw[start:index]) - start = index + 1 - } - } - _, _ = digest.Write(raw[start:]) - var result [32]byte - copy(result[:], digest.Sum(nil)) - return result -} - -func businessSystemPromptCarrier(body []byte, carrier string) (gjson.Result, []byte, bool) { - field := "instructions" - if carrier == BusinessSystemPromptCarrierSystemMessage { - field = "messages" - } - // The existing read-only view does not copy a large messages array. Reject - // duplicate carriers: encoding/json and gjson select different duplicates, - // so an insertion index would not be an unambiguous ownership proof. - view := parseRawJSONView(body) - if !view.IsObject() { - return gjson.Result{}, nil, false - } - var value gjson.Result - matches := 0 - view.ForEach(func(key, candidate gjson.Result) bool { - if key.String() == field { - matches++ - value = candidate - } - return matches < 2 - }) - if matches == 0 { - return value, nil, true - } - if matches != 1 || value.Index <= 0 || value.Index+len(value.Raw) > len(body) { - return value, nil, false - } - return value, body[value.Index : value.Index+len(value.Raw)], true -} - +// Only exact structured echoes and integrity checks use these insertion +// proofs. Sending and retrying always start from the caller's clean body. func cacheBusinessSystemPromptState(input, output []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, application BusinessSystemPromptApplication) businessSystemPromptRequestState { - state := businessSystemPromptRequestState{application: application, snapshot: snapshot, target: target, inputHash: sha256.Sum256(input), output: append([]byte(nil), output...)} - if application.RulesPlan != nil { - state.rulesUndo = promptRulesUndo(input, output, application) - return state - } - if !application.Applied { - return state - } - state.undo = businessSystemPromptUndo{present: true, carrier: application.Carrier, server: strings.TrimSpace(application.ServerInstructions)} - before, beforeRaw, beforeUnique := businessSystemPromptCarrier(input, application.Carrier) - after, afterRaw, afterUnique := businessSystemPromptCarrier(output, application.Carrier) - if !beforeUnique || !afterUnique { - return state - } - undo := state.undo - undo.beforeExists, undo.beforeHash, undo.afterHash = before.Exists(), businessSystemPromptCarrierHash(beforeRaw), businessSystemPromptCarrierHash(afterRaw) - switch application.Carrier { - case BusinessSystemPromptCarrierInstructions: - undo.valid = after.Type == gjson.String - undo.restorable = !before.Exists() || len(beforeRaw) <= businessSystemPromptRestoreMaxBytes - if before.Exists() && undo.restorable { - undo.instructions = append([]byte(nil), beforeRaw...) - } - case BusinessSystemPromptCarrierSystemMessage: - undo.valid, undo.restorable = before.IsArray() && after.IsArray(), true - before.ForEach(func(_, message gjson.Result) bool { - role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) - if role != "system" && role != "developer" { - return false - } - undo.messageIndex++ - return true - }) - } - state.undo = undo - return state -} - -func inheritBusinessSystemPromptProvenance(next, previous businessSystemPromptRequestState) businessSystemPromptRequestState { - if next.application.RulesPlan != nil || previous.application.RulesPlan != nil { - for _, proof := range previous.rulesUndo { - present := false - for _, current := range next.rulesUndo { - if current.field == proof.field { - present = true - break - } - } - if !present { - next.rulesUndo = append(next.rulesUndo, proof) - } - } - return next - } - next.historyUncertain = previous.historyUncertain - if !next.application.Applied { - next.undo, next.otherUndo = previous.undo, previous.otherUndo - return next - } - for _, old := range [...]businessSystemPromptUndo{previous.undo, previous.otherUndo} { - if !old.present { - continue - } - if old.carrier == next.undo.carrier { - // A compatible planner need not always return identical text. Keep - // no unbounded text history: unknown bodies must now fail closed. - if old.server != next.undo.server { - next.historyUncertain = true - } - continue - } - next.otherUndo = old - } - return next -} - -func restoreBusinessSystemPromptBody(body []byte, state businessSystemPromptRequestState) ([]byte, error) { - if state.application.RulesPlan != nil || len(state.rulesUndo) > 0 { - return restorePromptRules(body, state.rulesUndo) - } - if (!state.application.Applied && !state.undo.present && !state.otherUndo.present) || sha256.Sum256(body) == state.inputHash { - return body, nil - } - if state.application.Applied && !state.undo.present { - return nil, ErrBusinessSystemPromptUnavailable - } - // The one cached output was built from an already-clean input. Only its - // latest insertion needs removal; older carrier data is known original. - if bytes.Equal(body, state.output) { - return restoreBusinessSystemPromptCarrier(body, state.undo) - } - if state.historyUncertain { - return nil, ErrBusinessSystemPromptUnavailable - } - clean, err := restoreBusinessSystemPromptCarrier(body, state.undo) - if err != nil { - return nil, err - } - if state.otherUndo.present && !businessSystemPromptPriorCarrierUnchanged(body, state) { - return restoreBusinessSystemPromptCarrier(clean, state.otherUndo) - } - return clean, nil -} - -func businessSystemPromptPriorCarrierUnchanged(body []byte, state businessSystemPromptRequestState) bool { - if !state.otherUndo.present || len(state.output) == 0 { - return false - } - value, raw, unique := businessSystemPromptCarrier(body, state.otherUndo.carrier) - cached, cachedRaw, cachedUnique := businessSystemPromptCarrier(state.output, state.otherUndo.carrier) - return unique && cachedUnique && value.Exists() == cached.Exists() && businessSystemPromptCarrierHash(raw) == businessSystemPromptCarrierHash(cachedRaw) -} - -func restoreBusinessSystemPromptCarrier(body []byte, undo businessSystemPromptUndo) ([]byte, error) { - if !undo.present { - return body, nil - } - value, raw, unique := businessSystemPromptCarrier(body, undo.carrier) - if !unique { - return nil, ErrBusinessSystemPromptUnavailable - } - // Absence/before is clean only for this carrier, not for every previously - // used carrier. The caller must continue checking the other bounded proof. - if !value.Exists() { - return body, nil - } - if !undo.valid { - return nil, ErrBusinessSystemPromptUnavailable - } - hash := businessSystemPromptCarrierHash(raw) - if value.Exists() == undo.beforeExists && hash == undo.beforeHash { - return body, nil - } - if !value.Exists() || hash != undo.afterHash || !undo.restorable { - return nil, ErrBusinessSystemPromptUnavailable + return businessSystemPromptRequestState{ + application: application, snapshot: snapshot, target: target, + inputHash: sha256.Sum256(input), + rulesUndo: promptRulesUndo(input, output, application), } - if undo.carrier == BusinessSystemPromptCarrierInstructions { - if undo.beforeExists { - return sjson.SetRawBytes(body, "instructions", undo.instructions) - } - return sjson.DeleteBytes(body, "instructions") - } - return sjson.DeleteBytes(body, "messages."+strconv.Itoa(undo.messageIndex)) } -// Restore in the source protocol before a converter can relocate our carrier -// into customer control messages. Never search/delete a system message by text. -func restoreBusinessSystemPromptBeforeConversion(ctx *gin.Context, body []byte, protocol string) ([]byte, error) { - if ctx == nil { - return body, nil - } - value, exists := businessSystemPromptRequestGet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol)) - if !exists { - return body, nil - } - state, ok := value.(businessSystemPromptRequestState) - if !ok { - return nil, ErrBusinessSystemPromptUnavailable - } - return restoreBusinessSystemPromptBody(body, state) +// Compatibility wrappers delegate to the protocol-neutral send service. All +// production callers use them only at their final wire boundary. +func (s *OpenAIGatewayService) applyBusinessSystemPrompt(body []byte, account *Account, protocol string, compact bool) ([]byte, BusinessSystemPromptApplication, error) { + return s.applyBusinessSystemPromptForRequest(nil, body, account, protocol, compact) } -// Another platform may have rebuilt a clean native carrier. Preserve it only -// when bounded carrier inspection proves our actual insertion text is absent; -// user messages are not searched and matching control text is never deleted. -func businessSystemPromptCarrierExcludesInsertion(body []byte, undo businessSystemPromptUndo) bool { - value, raw, unique := businessSystemPromptCarrier(body, undo.carrier) - if !unique { - return false - } - if !value.Exists() { - return true - } - if undo.server == "" || len(raw) > businessSystemPromptRestoreMaxBytes { - return false - } - if undo.carrier == BusinessSystemPromptCarrierInstructions { - return value.Type == gjson.String && !strings.Contains(value.String(), undo.server) - } - if undo.carrier != BusinessSystemPromptCarrierSystemMessage || !value.IsArray() { - return false - } - clean := true - value.ForEach(func(_, message gjson.Result) bool { - if !message.IsObject() || hasDuplicateJSONObjectKeys(message) { - clean = false - return false - } - role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) - if role != "system" && role != "developer" { - return true - } - content := message.Get("content") - clean = content.Type == gjson.String && !strings.Contains(content.String(), undo.server) - return clean - }) - return clean -} - -func businessSystemPromptExcludesAllInsertions(body []byte, state businessSystemPromptRequestState) bool { - if state.application.RulesPlan != nil || len(state.rulesUndo) > 0 { - return false - } - if state.historyUncertain || (state.application.Applied && !state.undo.present && !state.otherUndo.present) { - return false - } - if state.undo.present && !businessSystemPromptCarrierExcludesInsertion(body, state.undo) { - return false - } - return !state.otherUndo.present || businessSystemPromptPriorCarrierUnchanged(body, state) || businessSystemPromptCarrierExcludesInsertion(body, state.otherUndo) -} - -func restoreBusinessSystemPromptForExcludedTarget(ctx *gin.Context, body []byte, protocol string) ([]byte, error) { - if ctx == nil { - return body, nil - } - value, exists := businessSystemPromptRequestGet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol)) - if !exists { - return body, nil - } - state, ok := value.(businessSystemPromptRequestState) - if !ok { - return nil, ErrBusinessSystemPromptUnavailable - } - clean, err := restoreBusinessSystemPromptBody(body, state) - if err == nil { - return clean, nil - } - if businessSystemPromptExcludesAllInsertions(body, state) { - return body, nil - } - return nil, ErrBusinessSystemPromptUnavailable -} - -func (s *OpenAIGatewayService) applyBusinessSystemPrompt( - body []byte, - account *Account, - protocol string, - compact bool, -) ([]byte, BusinessSystemPromptApplication, error) { - if s == nil || s.businessPromptService == nil || account == nil || !account.IsOpenAI() { - return body, BusinessSystemPromptApplication{}, nil - } - snapshot, ok := s.businessPromptService.CurrentSnapshot() - if !ok { - return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - if snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { - if err := s.businessPromptService.prepareBusinessSystemPromptSnapshot(&snapshot); err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - } - if snapshot.Enabled && (!compact || snapshot.CompactEnabled) { - var err error - snapshot, err = s.businessPromptService.compileBusinessSystemPromptSnapshot(snapshot) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - } - return ApplyBusinessSystemPromptToJSON(body, snapshot, enrichPromptTarget(nil, body, businessSystemPromptTargetForAccount(account, protocol, compact))) -} - -func (s *OpenAIGatewayService) businessSystemPromptSnapshotForRequest( - ctx *gin.Context, - account *Account, -) (BusinessSystemPromptSnapshot, bool, error) { - if s == nil || s.businessPromptService == nil || account == nil || !account.IsOpenAI() { +func (s *OpenAIGatewayService) businessSystemPromptSnapshotForRequest(c *gin.Context, account *Account) (BusinessSystemPromptSnapshot, bool, error) { + if s == nil || s.businessPromptService == nil || account == nil { return BusinessSystemPromptSnapshot{}, false, nil } - if ctx != nil { - if value, exists := businessSystemPromptRequestGet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestSnapshotKey, "")); exists { - if snapshot, ok := value.(BusinessSystemPromptSnapshot); ok { - return snapshot, true, nil - } - } - } - snapshot, ok := s.businessPromptService.CurrentSnapshot() - if !ok { - return BusinessSystemPromptSnapshot{}, true, ErrBusinessSystemPromptUnavailable - } - if snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { - if err := s.businessPromptService.prepareBusinessSystemPromptSnapshot(&snapshot); err != nil { - return BusinessSystemPromptSnapshot{}, true, err - } - } - if ctx != nil { - businessSystemPromptRequestSet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestSnapshotKey, ""), snapshot) - } - return snapshot, true, nil + snapshot, err := s.businessPromptService.snapshotForRequest(c) + return snapshot, true, err } -// Freeze prompt content and retain local rewrite provenance, while rechecking -// execution admission on every attempt. Admitted retries can reuse their bytes -// without appending the server prompt a second time. -func (s *OpenAIGatewayService) applyBusinessSystemPromptForRequest( - ctx *gin.Context, - body []byte, - account *Account, - protocol string, - compact bool, -) (out []byte, applied BusinessSystemPromptApplication, returnErr error) { - defer func() { writePromptDeliveryError(ctx, returnErr) }() - target := enrichPromptTarget(ctx, body, businessSystemPromptTargetForAccount(account, protocol, compact)) - if s != nil && s.businessPromptService != nil && s.businessPromptService.accountRepo != nil && account != nil && account.IsOpenAI() { - fresh, err := s.businessPromptService.accountRepo.GetByID(promptPolicyRequestContext(ctx), account.ID) - if err != nil || fresh == nil { - return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - freshTarget := businessSystemPromptTargetForAccount(fresh, protocol, compact) - target.BindingJSON = freshTarget.BindingJSON - } - // Platform eligibility precedes policy invocation or application reuse. - // Local provenance can still undo an earlier OpenAI insertion before an - // unrelated platform's transform; clean customer carriers stay untouched. - if s == nil || s.businessPromptService == nil || account == nil || !account.IsOpenAI() { - clean, err := restoreBusinessSystemPromptForExcludedTarget(ctx, body, protocol) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - rememberBusinessSystemPromptTarget(ctx, target) - return clean, BusinessSystemPromptApplication{}, nil - } - if ctx != nil { - applicationKey := businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol) - if value, exists := businessSystemPromptRequestGet(ctx, applicationKey); exists { - if state, ok := value.(businessSystemPromptRequestState); ok { - frozen := state.snapshot - if frozen.Revision < 1 && state.application.Applied { - return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - application, err := planBusinessSystemPromptWithInvoker(promptPolicyRequestContext(ctx), body, frozen, target, promptPlanInvoke) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - previousPlan := state.application - previousPlan.ClientInstructions = "" - if state.target == target && sameBusinessPromptPlan(previousPlan, application) { - rememberBusinessSystemPromptTarget(ctx, target) - if state.inputHash == sha256.Sum256(body) { - return append([]byte(nil), state.output...), state.application, nil - } - if bytes.Equal(body, state.output) || (state.application.RulesPlan != nil && promptRulesCarrierMatches(body, state)) || (state.application.RulesPlan == nil && !state.historyUncertain && ((!state.application.Applied && !state.undo.present && !state.otherUndo.present) || businessSystemPromptAlreadyApplied(body, state.application, protocol))) { - return body, state.application, nil - } - } - clean, restoreErr := restoreBusinessSystemPromptBody(body, state) - if restoreErr != nil { - // An admitted same-target retry may have rebuilt a new client - // carrier. No removal is attempted in that case. Revocation or - // cross-target reuse always requires provenance for any undo. - if state.target != target || !application.Applied || !businessSystemPromptExcludesAllInsertions(body, state) { - return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - clean = body - } - updated, application, err := applyBusinessSystemPromptApplication(clean, application) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - next := cacheBusinessSystemPromptState(clean, updated, frozen, target, application) - next = inheritBusinessSystemPromptProvenance(next, state) - businessSystemPromptRequestSet(ctx, applicationKey, next) - rememberBusinessSystemPromptTarget(ctx, target) - return updated, application, nil - } else { - return nil, BusinessSystemPromptApplication{}, ErrBusinessSystemPromptUnavailable - } - } - } - snapshot, eligible, err := s.businessSystemPromptSnapshotForRequest(ctx, account) - if err != nil { - return nil, BusinessSystemPromptApplication{}, err - } - if !eligible { +func (s *OpenAIGatewayService) applyBusinessSystemPromptForRequest(c *gin.Context, body []byte, account *Account, protocol string, compact bool) ([]byte, BusinessSystemPromptApplication, error) { + if s == nil || s.businessPromptService == nil { return body, BusinessSystemPromptApplication{}, nil } - if snapshot.Enabled && (!compact || snapshot.CompactEnabled) { - if ctx != nil { - if value, exists := businessSystemPromptRequestGet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestCompiledKey, "")); exists { - if compiled, ok := value.(BusinessSystemPromptSnapshot); ok && compiled.Revision == snapshot.Revision { - snapshot = compiled - } - } - } - if snapshot.EffectiveSHA256 == "" && - snapshot.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { - compiled, compileErr := s.businessPromptService.compileBusinessSystemPromptSnapshot(snapshot) - if compileErr != nil { - return nil, BusinessSystemPromptApplication{}, compileErr - } - snapshot = compiled - if ctx != nil { - businessSystemPromptRequestSet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestCompiledKey, ""), snapshot) - } - } - } - updated, application, err := ApplyBusinessSystemPromptToJSONContext(promptPolicyRequestContext(ctx), body, snapshot, target) - if err != nil { - return nil, application, err - } - if ctx != nil { - businessSystemPromptRequestSet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestApplicationKey, protocol), cacheBusinessSystemPromptState(body, updated, snapshot, target, application)) - } - rememberBusinessSystemPromptTarget(ctx, target) - return updated, application, nil -} - -func businessSystemPromptAlreadyApplied(body []byte, application BusinessSystemPromptApplication, protocol string) bool { - if !application.Applied { - return false - } - switch protocol { - case BusinessSystemPromptProtocolResponses: - instructions := gjson.GetBytes(body, "instructions") - return instructions.Exists() && instructions.Type == gjson.String && - instructions.String() == MergeBusinessSystemPromptInstructions(application.ClientInstructions, application.ServerInstructions) - case BusinessSystemPromptProtocolChat: - messages := gjson.GetBytes(body, "messages") - if !messages.IsArray() { - return false - } - for _, message := range messages.Array() { - if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") && - strings.TrimSpace(message.Get("content").String()) == application.ServerInstructions { - return true - } - } - } - return false + return s.businessPromptService.ApplyForSend(c, account, body, protocol, compact) } func businessSystemPromptApplicationFromRequest(ctx *gin.Context, protocol string) (BusinessSystemPromptApplication, bool) { @@ -592,7 +109,7 @@ func businessSystemPromptApplicationFromRequest(ctx *gin.Context, protocol strin if state, ok := value.(businessSystemPromptRequestState); ok { if current, exists := businessSystemPromptRequestGet(ctx, businessSystemPromptContextKey(ctx, businessSystemPromptRequestTargetKey, "")); exists { if target, ok := current.(BusinessSystemPromptTarget); ok && - (target.AccountID != state.target.AccountID || target.Platform != state.target.Platform || target.AccountType != state.target.AccountType || target.Compact != state.target.Compact) { + (target.AccountID != state.target.AccountID || target.ProviderPlatform != state.target.ProviderPlatform || target.Platform != state.target.Platform || target.AccountType != state.target.AccountType || target.Protocol != state.target.Protocol || target.Compact != state.target.Compact) { return BusinessSystemPromptApplication{}, false } } @@ -614,8 +131,10 @@ func (s *OpenAIGatewayService) rewriteBusinessSystemPromptJSONForRequest(c *gin. } func (s *OpenAIGatewayService) rewriteBusinessSystemPromptJSONForAnyRequest(c *gin.Context, body []byte) []byte { - body = s.rewriteBusinessSystemPromptJSONForRequest(c, body, BusinessSystemPromptProtocolResponses) - return s.rewriteBusinessSystemPromptJSONForRequest(c, body, BusinessSystemPromptProtocolChat) + for _, protocol := range []string{BusinessSystemPromptProtocolResponses, BusinessSystemPromptProtocolChat, BusinessSystemPromptProtocolMessages, BusinessSystemPromptProtocolGemini} { + body = s.rewriteBusinessSystemPromptJSONForRequest(c, body, protocol) + } + return body } func (s *OpenAIGatewayService) rewriteBusinessSystemPromptSSEForRequest(c *gin.Context, body []byte, protocol string) []byte { @@ -666,7 +185,7 @@ func beginBusinessSystemPromptRequestTurn(ctx *gin.Context) { // its historical bytes so Cindy's SHA256(old expanded key) remains unchanged. func businessSystemPromptCacheNamespace(application BusinessSystemPromptApplication) string { if application.RulesPlan != nil && application.Applied { - return ":prompt-rules:v1:" + strconv.FormatInt(application.Revision, 10) + ":" + application.RulesPlan.SHA256 + return ":prompt-rules:v2:" + strconv.FormatInt(application.Revision, 10) + ":" + application.RulesPlan.SHA256 } if !application.Applied || application.Revision < 1 || strings.TrimSpace(application.SHA256) == "" { return "" @@ -719,18 +238,6 @@ func rewriteBusinessSystemPromptCacheKey(c *gin.Context, body []byte, applicatio if !value.Exists() || value.Type != gjson.String { return body, nil } - if application.RulesPlan != nil && !application.Applied && c != nil { - if stored, ok := c.Get(businessSystemPromptCacheIdentityKey); ok { - if identities, ok := stored.(*businessSystemPromptCacheIdentities); ok { - for _, identity := range identities.values { - if value.String() == identity.wire { - return sjson.SetBytes(body, "prompt_cache_key", identity.source) - } - } - } - } - return body, nil - } effective := deriveBusinessSystemPromptCacheKey(c, value.String(), application) if effective == "" || effective == value.String() { return body, nil diff --git a/backend/internal/service/openai_chat_reasoning_replay.go b/backend/internal/service/openai_chat_reasoning_replay.go index 75953180e69d..641fc108a358 100644 --- a/backend/internal/service/openai_chat_reasoning_replay.go +++ b/backend/internal/service/openai_chat_reasoning_replay.go @@ -289,6 +289,10 @@ func (s *OpenAIGatewayService) prepareOpenAIChatReasoningReplay(ctx context.Cont if err != nil { return nil, wireBody } + scope, err = s.businessPromptReplayScope(c, account, wireBody, scope) + if err != nil { + return nil, wireBody + } input, ok := openAIChatReasoningInput(wireBody) if !ok { return nil, wireBody diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 76450f0d5888..c243ee1ec49b 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -209,17 +209,26 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( bearerToken string, userAgent string, grokCacheIdentity string, + compactOverride ...bool, ) (*http.Response, error) { // DeepSeek thinking mode 要求历史 assistant 回传 reasoning_content。 // Responses→CC 回退在加密-only / 缺 reasoning item 且缓存未命中时会漏掉该 // 字段,上游 400 "The `reasoning_content` in the thinking mode must be // passed back to the API"。在共用出站点补空格占位,真实明文不覆盖。 body = ensureDeepSeekChatReasoningPlaceholders(account, body) + businessSystemPromptRequestSet(c, businessSystemPromptStrictChatRoleKey, requiresSystemChatRole(account, targetURL)) + compact := isOpenAIResponsesCompactPath(c) + if len(compactOverride) > 0 { + compact = compactOverride[0] + } var promptErr error - body, promptErr = s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolChat, isOpenAIResponsesCompactPath(c)) + body, promptErr = s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolChat, compact) if promptErr != nil { return nil, promptErr } + if application, ok := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolChat); ok { + grokCacheIdentity = grokBusinessPromptCacheIdentity(grokCacheIdentity, application) + } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body)) releaseUpstreamCtx() diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 64177fb9fc34..4b38d8354593 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -85,16 +85,6 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( c.Set(openAIChatReasoningReplayContextKey, (*openAIChatReasoningReplay)(nil)) c.Set(openAIReasoningRecoveryContextKey, (*openAIReasoningRecoveryState)(nil)) } - nativePromptProtocol := BusinessSystemPromptProtocolChat - if !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() { - nativePromptProtocol = BusinessSystemPromptProtocolResponses - } - cleanBody, restoreErr := restoreBusinessSystemPromptBeforeConversion(c, body, nativePromptProtocol) - if restoreErr != nil { - writeChatCompletionsError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - return nil, restoreErr - } - body = cleanBody rememberOpenCodeInboundBody(c, body) beginUpstreamResponseModelObservation(c) if account != nil && account.IsOpenAI() { @@ -411,34 +401,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return nil, policyErr } responsesBody = updatedBody - updatedPromptBody, application, promptErr := s.applyBusinessSystemPromptForRequest( - c, responsesBody, account, BusinessSystemPromptProtocolResponses, false, - ) - if promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - writeChatCompletionsError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - } - return nil, promptErr - } else { - responsesBody = updatedPromptBody - if application.Applied { - responsesBody, promptErr = rewriteBusinessSystemPromptCacheKey(c, responsesBody, application) - if promptErr != nil { - return nil, promptErr - } - } - } upstreamPromptCacheKey := promptCacheKey - if normalizedBody, changed, normalizeErr := normalizeCindyManagedPromptCacheKey(responsesBody, c, account); normalizeErr != nil { - return nil, fmt.Errorf("normalize final Chat-to-Responses Cindy prompt_cache_key: %w", normalizeErr) - } else if changed { - responsesBody = normalizedBody - upstreamPromptCacheKey = strings.TrimSpace(gjson.GetBytes(responsesBody, "prompt_cache_key").String()) - observeCindyManagedPromptCacheNormalization(c, true) - } - if application.Applied { - upstreamPromptCacheKey = businessSystemPromptUpstreamCacheKey(c, responsesBody, promptCacheKey, application) - } responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) // 5. Get access token @@ -449,27 +412,12 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // 6. Build upstream request upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false) + upstreamReq, err := s.buildUpstreamRequestPrepared(upstreamCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false, false) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } - if upstreamPromptCacheKey != "" { - apiKeyID := getAPIKeyIDFromContext(c) - sessionKey := upstreamPromptCacheKey - if !compatPromptCacheTenantIsolated { - sessionKey = isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey) - } - if account.UsesOpenAICodexProtocol() { - // Codex 协议账号:最终形态只有连字符会话头;构造器已就位的连字符头保留, - // 缺失时才用隔离会话 ID 补齐。 - fillCodexSessionIdentityHeaders(upstreamReq.Header, generateSessionUUID(sessionKey)) - } else { - upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) - } - } - // 7. Send request proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { @@ -494,30 +442,47 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( _ = upstreamReq.Body.Close() } responsesBody = replayBody - upstreamReq = cloneOpenAIChatRequestWithBody(upstreamReq, responsesBody) recovery := s.newOpenAIReasoningRecoveryState(ctx, c, account, token) defer recovery.Close() recovery.SetRejectedCallback(replay.InvalidateRejected) var resp *http.Response var result *OpenAIForwardResult var handleErr error + var wireBody []byte // Keep the builder's transport context as the uncanceled base. Each stream // attempt must cancel before closing its body without canceling the single // same-source reasoning recovery; PrepareRequest reapplies client cancellation // and its deadline to that recovery attempt. upstreamRequestCtx := upstreamReq.Context() for { + upstreamReq, err = s.buildUpstreamRequest(upstreamRequestCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false) + if err != nil { + return nil, err + } cancelUpstream := func() {} if clientStream { var attemptCtx context.Context attemptCtx, cancelUpstream = context.WithCancel(upstreamRequestCtx) upstreamReq = upstreamReq.WithContext(attemptCtx) } - upstreamReq, responsesBody, err = recovery.PrepareRequest(upstreamReq, responsesBody, proxyURL) + upstreamReq, responsesBody, wireBody, err = prepareBusinessPromptReasoningRequest(c, recovery, upstreamReq, responsesBody, proxyURL) if err != nil { cancelUpstream() return nil, recovery.StopError(err) } + application, _ := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses) + finalCacheKey := businessSystemPromptUpstreamCacheKey(c, wireBody, upstreamPromptCacheKey, application) + if finalCacheKey != "" { + sessionKey := finalCacheKey + if !compatPromptCacheTenantIsolated { + sessionKey = isolateOpenAIUpstreamSessionID(getAPIKeyIDFromContext(c), codexAccountIdentitySource(c, account), finalCacheKey) + } + if account.UsesOpenAICodexProtocol() { + fillCodexSessionIdentityHeaders(upstreamReq.Header, generateSessionUUID(sessionKey)) + } else { + upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) + } + } replay.SetSentBody(responsesBody) resp, err = s.doOpenAICodexUpstream(upstreamReq, account, proxyURL) if err != nil { @@ -541,8 +506,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( respBody, upstreamMsg := s.readOpenAIUpstreamError(resp, c) if retryBody, retry := recovery.TryRecover(resp.StatusCode, resp.Header, respBody, false); retry { closeUpstreamResponse() - upstreamReq = cloneOpenAIChatRequestWithBody(upstreamReq, retryBody) - responsesBody = retryBody + responsesBody, err = projectReasoningCipherEdits(responsesBody, wireBody, retryBody) + if err != nil { + return nil, err + } continue } if recovery.RecoveryAttempt() { @@ -579,8 +546,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } closeUpstreamResponse() if retryBody, retry := recovery.TryRecoverError(handleErr); retry { - upstreamReq = cloneOpenAIChatRequestWithBody(upstreamReq, retryBody) - responsesBody = retryBody + responsesBody, err = projectReasoningCipherEdits(responsesBody, wireBody, retryBody) + if err != nil { + return nil, err + } continue } handleErr = recovery.StopError(handleErr) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 9df78675eae4..7cd0a6229a0c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -130,20 +130,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, policyErr } upstreamBody = updatedBody - if updatedPromptBody, application, promptErr := s.applyBusinessSystemPromptForRequest( - c, upstreamBody, account, BusinessSystemPromptProtocolChat, false, - ); promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - writeChatCompletionsError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - } - return nil, promptErr - } else { - upstreamBody = updatedPromptBody - upstreamBody, promptErr = rewriteBusinessSystemPromptCacheKey(c, upstreamBody, application) - if promptErr != nil { - return nil, promptErr - } - } if account.Platform == PlatformGrok { strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody) if stripErr != nil { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index d3bc6f389bc0..ce3a028c7018 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -852,48 +852,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // A prior rejection is not evidence that an encrypted state item is // dispensable. Preserve the incoming history and let its source validate it. - // Business System Prompt is deliberately the final service-owned prompt - // layer. All legacy Codex/image/compat transforms above run first, so the - // feature can be disabled without changing their request bytes. - updatedBody, promptApplication, promptErr := s.applyBusinessSystemPromptForRequest( - c, body, account, BusinessSystemPromptProtocolResponses, compactPath, - ) - if promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ - "type": "system_prompt_unavailable", "code": "system_prompt_unavailable", - "message": "business system prompt is temporarily unavailable", - }}) - } - return nil, promptErr - } else { - body = updatedBody - if promptApplication.Applied { - body, promptErr = rewriteBusinessSystemPromptCacheKey(c, body, promptApplication) - if promptErr != nil { - return nil, promptErr - } - requestView = newOpenAIRequestView(body) - reqBody = nil - } - } - finalCacheBody, finalCacheChanged, finalCacheErr := normalizeCindyManagedPromptCacheKey(body, c, account) - if finalCacheErr != nil { - return nil, fmt.Errorf("normalize final Cindy prompt_cache_key: %w", finalCacheErr) - } - if finalCacheChanged { - body = finalCacheBody - observeCindyManagedPromptCacheNormalization(c, true) - requestView = newOpenAIRequestView(body) - reqBody = nil - } - // Prefer the final service-owned cache key when it remains on the wire (for - // example after business-prompt rewriting), while retaining the pre-transform - // seed for compatibility bridges that intentionally strip the body field. - if effectivePromptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()); effectivePromptCacheKey != "" { - promptCacheKey = effectivePromptCacheKey - } - logBusinessSystemPromptObservation(ctx, c, promptApplication, wsDecision.Transport, wsDecision.Reason) imageBillingModel := "" imageSizeTier := "" imageInputSize := "" @@ -1160,6 +1118,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco compactModelFallbackRetried := false agentTaskRecoveryTried := false rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body) + var wireBody []byte for { // Read the final attempt payload. A compatibility retry may have changed // the request, so neither the original alias nor prior attempt is usage @@ -1198,7 +1157,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - upstreamReq, body, err = reasoningRecovery.PrepareRequest(upstreamReq, body, proxyURL) + upstreamReq, body, wireBody, err = prepareBusinessPromptReasoningRequest(c, reasoningRecovery, upstreamReq, body, proxyURL) if err != nil { if headerGuard != nil { headerGuard.close() @@ -1209,7 +1168,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // recovery retry edits) and before zstd inside doOpenAICodexUpstream. The // check compares the slice held here and never reads req.Body. integrityOpts.UpstreamModel = upstreamModel - if integrityErr := s.checkStagedRequestIntegrity(c, account, "http_forward", body, integrityOpts); integrityErr != nil { + if integrityErr := s.checkStagedRequestIntegrity(c, account, "http_forward", wireBody, integrityOpts); integrityErr != nil { if headerGuard != nil { headerGuard.close() } @@ -1262,7 +1221,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) if retryBody, retry := reasoningRecovery.TryRecover(resp.StatusCode, resp.Header, respBody, false); retry { - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } requestView = newOpenAIRequestView(body) reqBody = nil continue @@ -1310,7 +1272,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) return nil, NewOpenAIContinuationStateUnavailableError(resp.StatusCode, resp.Header, respBody) } - if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil { + if retryBody, reason, changed, retryErr := normalizeBusinessPromptRejectedFieldRetryBody(c, resp.StatusCode, body, respBody); retryErr != nil { return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr) } else if changed && rejectedFieldRetryState.Allow(retryBody) { body = retryBody @@ -1414,7 +1376,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if err != nil { if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry { _ = resp.Body.Close() - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } requestView = newOpenAIRequestView(body) reqBody = nil continue @@ -1474,7 +1439,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if err != nil { if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry { _ = resp.Body.Close() - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } requestView = newOpenAIRequestView(body) reqBody = nil continue @@ -1585,6 +1553,12 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { } func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { + return s.buildUpstreamRequestPrepared(ctx, c, account, body, token, isStream, promptCacheKey, isCodexCLI, true) +} + +// Replay preparation needs the real endpoint and auth identity, but retains a +// clean request. Only the final build is allowed to apply the business prompt. +func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool, includePrompt bool) (*http.Request, error) { // Determine target URL based on account type var targetURL string switch account.Type { @@ -1620,9 +1594,11 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. return nil, fmt.Errorf("normalize compatible Responses reasoning summary: %w", err) } - body, err = s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) - if err != nil { - return nil, err + if includePrompt { + body, err = s.finalizeBusinessPromptForSend(c, account, body, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) + if err != nil { + return nil, err + } } req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 3fe3e09dbc07..2a8b96ee4890 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -110,7 +110,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( upstreamStart := time.Now() var resp *http.Response for attempt := 0; ; attempt++ { - upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg, s.settingService) + upstreamReq, buildErr := s.buildGrokConversationRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity) if buildErr != nil { return nil, buildErr } @@ -1572,6 +1572,34 @@ func addOpenAIUsage(dst *OpenAIUsage, usage OpenAIUsage) { dst.ImageOutputTokens += usage.ImageOutputTokens } +// Conversation sends share the final prompt boundary. Auxiliary vision probes +// and standalone image/audio endpoints keep the lower-level transport builder. +func (s *OpenAIGatewayService) buildGrokConversationRequest(ctx context.Context, c *gin.Context, account *Account, clean []byte, token, cacheIdentity string) (*http.Request, error) { + body, err := s.finalizeBusinessPromptForSend(c, account, clean, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) + if err != nil { + return nil, err + } + if application, ok := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses); ok { + cacheIdentity = grokBusinessPromptCacheIdentity(cacheIdentity, application) + if cacheIdentity != "" { + body, err = sjson.SetBytes(body, "prompt_cache_key", cacheIdentity) + if err != nil { + return nil, err + } + } + } + return buildGrokResponsesRequest(ctx, c, account, body, token, cacheIdentity, s.cfg, s.settingService) +} + +func grokBusinessPromptCacheIdentity(identity string, application BusinessSystemPromptApplication) string { + if namespace := businessSystemPromptCacheNamespace(application); identity != "" && namespace != "" { + // Keep the provider's existing conversation UUID shape while separating + // the cache when the effective prompt selection changes. + return generateSessionUUID(identity + namespace) + } + return identity +} + func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config, settings ...*SettingService) (*http.Request, error) { targetURL, err := buildGrokResponsesURL(account, cfg, settings...) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 53b1cf1fde28..efa7f52da1ed 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -596,7 +596,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return nil, fmt.Errorf("get grok access token: %w", err) } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity, s.cfg, s.settingService) + upstreamReq, err := s.buildGrokConversationRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build grok responses bridge request: %w", err) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 09beaa6acd2f..86a5ef843506 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -330,34 +330,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, policyErr } responsesBody = updatedBody - updatedPromptBody, application, promptErr := s.applyBusinessSystemPromptForRequest( - c, responsesBody, account, BusinessSystemPromptProtocolResponses, false, - ) - if promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - writeAnthropicError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - } - return nil, promptErr - } else { - responsesBody = updatedPromptBody - if application.Applied { - responsesBody, promptErr = rewriteBusinessSystemPromptCacheKey(c, responsesBody, application) - if promptErr != nil { - return nil, promptErr - } - } - } upstreamPromptCacheKey := promptCacheKey - if normalizedBody, changed, normalizeErr := normalizeCindyManagedPromptCacheKey(responsesBody, c, account); normalizeErr != nil { - return nil, fmt.Errorf("normalize final Messages-to-Responses Cindy prompt_cache_key: %w", normalizeErr) - } else if changed { - responsesBody = normalizedBody - upstreamPromptCacheKey = strings.TrimSpace(gjson.GetBytes(responsesBody, "prompt_cache_key").String()) - observeCindyManagedPromptCacheNormalization(c, true) - } - if application.Applied { - upstreamPromptCacheKey = businessSystemPromptUpstreamCacheKey(c, responsesBody, promptCacheKey, application) - } responsesReq.ServiceTier = normalizedOpenAIServiceTierValue(gjson.GetBytes(responsesBody, "service_tier").String()) grokCacheIdentity := "" if account.Platform == PlatformGrok { @@ -393,7 +366,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) var upstreamReq *http.Request if account.Platform == PlatformGrok { - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) + upstreamReq, err = s.buildGrokConversationRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity) } else { upstreamReq, err = s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, isStream, upstreamPromptCacheKey, false) } @@ -401,6 +374,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + wireBody, err := businessPromptWireRequestBody(upstreamReq, responsesBody) + if err != nil { + return nil, err + } + application, _ := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses) + upstreamPromptCacheKey = businessSystemPromptUpstreamCacheKey(c, wireBody, promptCacheKey, application) // Override session_id with a deterministic UUID derived from the isolated // session key, ensuring different API keys produce different upstream sessions. @@ -457,7 +436,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( break } upstreamCtxRetry, releaseRetry := detachUpstreamContext(ctx) - upstreamReq, err = buildGrokResponsesRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) + upstreamReq, err = s.buildGrokConversationRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity) releaseRetry() if err != nil { return nil, fmt.Errorf("build grok retry request: %w", err) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index fa85eff758e4..26c86f9a5719 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -173,6 +173,11 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( // 的 base 取值同源(GetAnthropicProtocolBaseURL,adaptive 时是 Anthropic 协议 // 地址而非 CC/Responses 地址),详见 helper 注释。 body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetAnthropicProtocolBaseURL(), body) + var promptErr error + body, promptErr = s.finalizeBusinessPromptForSend(c, account, body, "messages", isOpenAIResponsesCompactPath(c)) + if promptErr != nil { + return nil, nil, promptErr + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { @@ -266,6 +271,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( contentType = "application/json" } body = reverseToolNamesIfPresent(c, body) + body = s.rewriteBusinessSystemPromptJSONForRequest(c, body, BusinessSystemPromptProtocolMessages) c.Data(resp.StatusCode, contentType, body) return &OpenAIForwardResult{ @@ -467,7 +473,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( } if !clientDisconnected { - restored := string(reverseToolNamesIfPresent(c, []byte(line))) + restored := string(reverseToolNamesIfPresent(c, s.rewriteBusinessSystemPromptSSEForRequest(c, []byte(line), BusinessSystemPromptProtocolMessages))) if _, err := io.WriteString(w, restored); err != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 87d6f36c4b7b..456f52086d6b 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -92,20 +91,6 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatBody = policyBody } } - if updatedPromptBody, application, promptErr := s.applyBusinessSystemPromptForRequest( - c, chatBody, account, BusinessSystemPromptProtocolChat, false, - ); promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - writeAnthropicError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - } - return nil, promptErr - } else { - chatBody = updatedPromptBody - chatBody, promptErr = rewriteBusinessSystemPromptCacheKey(c, chatBody, application) - if promptErr != nil { - return nil, promptErr - } - } // Provider normalization and policy caps can both change the converted effort. // Use the final outbound value for usage logs and billing. reasoningEffort := extractOpenAIReasoningEffortFromBody(chatBody, upstreamModel, billingModel, originalModel) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index f6e6347e4c1d..59aee92d2fa5 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -329,42 +329,6 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } body = updatedBody - // Keep the business policy as the final prompt-owning layer. In particular, - // it must not satisfy or bypass the legacy OAuth passthrough preflight above. - updatedPromptBody, promptApplication, promptErr := s.applyBusinessSystemPromptForRequest( - c, body, account, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c), - ) - if promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ - "type": "system_prompt_unavailable", "code": "system_prompt_unavailable", - "message": "business system prompt is temporarily unavailable", - }}) - } - return nil, promptErr - } else { - body = updatedPromptBody - body, promptErr = rewriteBusinessSystemPromptCacheKey(c, body, promptApplication) - if promptErr != nil { - return nil, promptErr - } - } - finalCacheBody, finalCacheChanged, finalCacheErr := normalizeCindyManagedPromptCacheKey(body, c, account) - if finalCacheErr != nil { - return nil, fmt.Errorf("normalize final passthrough Cindy prompt_cache_key: %w", finalCacheErr) - } - if finalCacheChanged { - body = finalCacheBody - observeCindyManagedPromptCacheNormalization(c, true) - } - logBusinessSystemPromptObservation( - ctx, - c, - promptApplication, - OpenAIUpstreamTransportHTTPSSE, - "account_passthrough", - ) - apiKey := getAPIKeyFromContext(c) // 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。 imageIntent := resolveOpenAIPassthroughImageIntent( @@ -452,6 +416,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( agentTaskRecoveryTried := false compactModelFallbackRetried := false var reasoningEffort *string + var wireBody []byte retryUpstream: var resp *http.Response @@ -471,7 +436,7 @@ retryUpstream: if buildErr != nil { return nil, buildErr } - upstreamReq, body, buildErr = reasoningRecovery.PrepareRequest(upstreamReq, body, proxyURL) + upstreamReq, body, wireBody, buildErr = prepareBusinessPromptReasoningRequest(c, reasoningRecovery, upstreamReq, body, proxyURL) if buildErr != nil { return nil, buildErr } @@ -480,7 +445,7 @@ retryUpstream: // without a snapshot are a no-op. integrityOpts := requestIntegrityForwardOptionsFromContext(c) integrityOpts.UpstreamModel = actualModel - if integrityErr := s.checkStagedRequestIntegrity(c, account, "http_passthrough", body, integrityOpts); integrityErr != nil { + if integrityErr := s.checkStagedRequestIntegrity(c, account, "http_passthrough", wireBody, integrityOpts); integrityErr != nil { return nil, integrityErr } reasoningRecovery.BindDiagnosticRequest(diagnosticIncomingBody, upstreamReq) @@ -508,7 +473,10 @@ retryUpstream: _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(probeBody)) if retryBody, retry := reasoningRecovery.TryRecover(resp.StatusCode, resp.Header, probeBody, false); retry { - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } continue } if reasoningRecovery.RecoveryAttempt() { @@ -615,7 +583,10 @@ retryUpstream: if err != nil { if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry { _ = resp.Body.Close() - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } goto retryUpstream } if reasoningRecovery.RecoveryAttempt() { @@ -650,7 +621,10 @@ retryUpstream: if err != nil { if retryBody, retry := reasoningRecovery.TryRecoverError(err); retry { _ = resp.Body.Close() - body = retryBody + body, err = projectReasoningCipherEdits(body, wireBody, retryBody) + if err != nil { + return nil, err + } goto retryUpstream } if reasoningRecovery.RecoveryAttempt() { diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index a141da871678..7ac5629f26ac 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -31,12 +31,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( compact bool, ) (*OpenAIForwardResult, error) { startTime := time.Now() - cleanBody, restoreErr := restoreBusinessSystemPromptBeforeConversion(c, body, BusinessSystemPromptProtocolResponses) - if restoreErr != nil { - writeOpenAIResponsesFallbackError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - return nil, restoreErr - } - body = cleanBody var responsesReq apicompat.ResponsesRequest if err := json.Unmarshal(body, &responsesReq); err != nil { @@ -122,20 +116,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } - if updatedPromptBody, application, promptErr := s.applyBusinessSystemPromptForRequest( - c, chatBody, account, BusinessSystemPromptProtocolChat, compact, - ); promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - writeOpenAIResponsesFallbackError(c, http.StatusServiceUnavailable, "system_prompt_unavailable", "business system prompt is temporarily unavailable") - } - return nil, promptErr - } else { - chatBody = updatedPromptBody - chatBody, promptErr = rewriteBusinessSystemPromptCacheKey(c, chatBody, application) - if promptErr != nil { - return nil, promptErr - } - } // /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个 // 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。 chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody) @@ -156,7 +136,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( if err != nil { return nil, err } - resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, chatBody, clientStream, apiKey, account.GetOpenAIUserAgent(), "") + resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, chatBody, clientStream, apiKey, account.GetOpenAIUserAgent(), "", compact) if err != nil { return nil, err } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b7f8366522d5..fb958d53ebde 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -543,51 +543,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } normalized = policyApplied - beginBusinessSystemPromptRequestTurn(c) - rememberPromptRequestedModel(c, raw) - businessPromptApplied := false - if updatedPromptPayload, application, promptErr := s.prepareBusinessPromptWSIngress( - c, normalized, account, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c), - ); promptErr != nil { - if errors.Is(promptErr, ErrBusinessSystemPromptUnavailable) { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusTryAgainLater, - "system_prompt_unavailable", - promptErr, - ) - } - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "invalid websocket request payload", - promptErr, - ) + if turn == 1 { + beginBusinessSystemPromptFirstWSTurn(c) } else { - businessPromptApplied = application.Applied - normalized = updatedPromptPayload - normalized, promptErr = rewriteBusinessSystemPromptCacheKey(c, normalized, application) - if promptErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "invalid websocket request payload", - promptErr, - ) - } + beginBusinessSystemPromptRequestTurn(c) } - if normalizedPayload, changed, normalizeErr := normalizeCindyManagedPromptCacheKey(normalized, c, account); normalizeErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "invalid websocket request payload", - normalizeErr, - ) - } else if changed { - normalized = normalizedPayload - promptCacheKey = strings.TrimSpace(gjson.GetBytes(normalized, "prompt_cache_key").String()) - observeCindyManagedPromptCacheNormalization(c, true) - } - // Cache-key rewriting is independent of Cindy's compatibility policy. - // Carry the final wire value into handshake fallback on every path. - if businessPromptApplied { - promptCacheKey = strings.TrimSpace(gjson.GetBytes(normalized, "prompt_cache_key").String()) + // A continuation may omit model; prompt scopes still use the accepted + // client model, before its mapping to the upstream wire ID. + rememberPromptRequestedModelName(c, originalModel) + if _, _, promptErr := s.prepareBusinessPromptWSIngress(c, normalized, account, BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)); promptErr != nil { + return openAIWSClientPayload{}, businessPromptWSCloseError(promptErr) } // Per-frame request integrity check: the client frame versus the frame // this parser produced. The later native wire stage (verified full replay, @@ -1194,7 +1159,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState committedHandshakeConnID := "" - sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) { + sendAndRelay := func(turn int, lease *openAIWSConnLease, cleanPayload, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) { policyCtx, policyErr := copyOpenAIWSProviderPricingContext(ctx, hooks, turn) if policyErr != nil { return nil, policyErr @@ -1258,21 +1223,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } return wroteDownstream } - wirePayload, promptErr := s.finalizeBusinessPromptWSIngress(c, account, payload) - if promptErr != nil { - return nil, businessPromptWSCloseError(promptErr) - } - if routingErr := s.guardCodexRoutingNativeModel(account, gjson.GetBytes(wirePayload, "model").String()); routingErr != nil { + if routingErr := s.guardCodexRoutingNativeModel(account, gjson.GetBytes(payload, "model").String()); routingErr != nil { return nil, routingErr } - if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(wirePayload), s.openAIWSWriteTimeout()); err != nil { + if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(payload), s.openAIWSWriteTimeout()); err != nil { return nil, wrapOpenAIWSIngressTurnError( "write_upstream", fmt.Errorf("write upstream websocket request: %w", err), false, ) } - s.observeNativeCodexWS(ctx, account, wsHeaders, lease.HandshakeHeaders(), wirePayload, lease.ConnID()) + s.observeNativeCodexWS(ctx, account, wsHeaders, lease.HandshakeHeaders(), payload, lease.ConnID()) if debugEnabled { logOpenAIWSModeDebug( "ingress_ws_turn_request_sent account_id=%d turn=%d conn_id=%s payload_bytes=%d", @@ -1382,9 +1343,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return nil, NewOpenAIContinuationStateUnavailableError(statusCode, lease.HandshakeHeaders(), upstreamMessage) } if !wroteDownstream && statusCode == http.StatusBadRequest && rejectedFieldRetryState != nil { - retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody( + retryBody, retryReason, changed, retryErr := normalizeBusinessPromptRejectedFieldRetryBody(c, statusCode, - payload, + cleanPayload, upstreamMessage, ) if retryErr != nil { @@ -1662,7 +1623,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentImageBillingModel := firstPayload.imageBillingModel currentImageSizeTier := firstPayload.imageSizeTier currentImageInputSize := firstPayload.imageInputSize - currentPayloadBytes := firstPayload.payloadBytes currentRequestedReasoningEffort := firstPayload.requestedReasoningEffort isStrictAffinityTurn := func(payload []byte) bool { hasAnchor := strings.TrimSpace(openAIWSPayloadStringFromRaw(payload, "previous_response_id")) != "" @@ -1759,7 +1719,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return classification, false } currentPayload = candidate - currentPayloadBytes = len(candidate) return classification, true } recoverIngressPrevResponseNotFound := func(relayErr error, turn int, connID string) bool { @@ -1801,7 +1760,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), ) currentPayload = updatedWithInput - currentPayloadBytes = len(updatedWithInput) turnRetry++ resetSessionLease(true) skipBeforeTurn = true @@ -1907,7 +1865,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } else { currentPayload = updatedPayload - currentPayloadBytes = len(updatedPayload) currentPreviousResponseID = expectedPrev logOpenAIWSModeInfo( "ingress_ws_function_call_output_prev_infer account_id=%d turn=%d conn_id=%s action=set_previous_response_id previous_response_id=%s", @@ -1999,6 +1956,19 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentPreviousResponseID = "" } } + // Build a send-only copy after full replay assembly. The clean payload + // remains the sole source for retries and the next turn's accumulator. + wirePayload, promptErr := s.finalizeBusinessPromptWSIngress(c, account, currentPayload) + if promptErr != nil { + return businessPromptWSCloseError(promptErr) + } + wireFields := gjson.GetManyBytes(wirePayload, "prompt_cache_key", "model", "service_tier") + finalHeaders, _, headerErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, + strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), wireFields[0].String(), wireFields[1].String(), wireFields[2].String()) + if headerErr != nil { + return headerErr + } + baseAcquireReq.Headers = finalHeaders forcePreferredConn := isStrictAffinityTurn(currentPayload) && strings.TrimSpace(preferredConnID) != "" if sessionLease == nil { acquiredLease, acquireErr := acquireTurnLease(turn, preferredConnID, forcePreferredConn, turnRetry > 0) @@ -2063,7 +2033,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } - result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize, currentRequestedReasoningEffort) + result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, wirePayload, len(wirePayload), currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize, currentRequestedReasoningEffort) if relayErr != nil { lastTurnClean = false if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { @@ -2251,7 +2221,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentImageBillingModel = nextPayload.imageBillingModel currentImageSizeTier = nextPayload.imageSizeTier currentImageInputSize = nextPayload.imageInputSize - currentPayloadBytes = nextPayload.payloadBytes currentRequestedReasoningEffort = nextPayload.requestedReasoningEffort rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload) storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 7c681020a6fd..d4844a404549 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -108,10 +108,15 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( } setOpenAIWSTurnMetadata(payload, turnMetadata) applyStagedCodexFingerprintClientMetadata(c, account, payload) - if promptErr := validateBusinessSystemPromptFinal(c, payloadAsJSONBytes(payload), BusinessSystemPromptProtocolResponses); promptErr != nil { + wirePayload, promptErr := s.finalizeBusinessPromptForSend(c, account, payloadAsJSONBytes(payload), BusinessSystemPromptProtocolResponses, isOpenAIResponsesCompactPath(c)) + if promptErr != nil { return nil, promptErr } - observePromptRulesFinalFromRequest(c, account, BusinessSystemPromptProtocolResponses) + // Decode into a new map: reqBody is the clean retry source owned by the + // caller and must never retain the server's inserted messages. + if err := decodeOpenAIJSONUseNumber(wirePayload, &payload); err != nil { + return nil, err + } // Final response.create payload versus the client body Forward staged. The // envelope edits above (type/stream/store/background, client_metadata) are // outside the compared set; the marshal only happens when a snapshot exists. @@ -124,11 +129,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( } previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - promptCacheKey := strings.TrimSpace(clientPromptCacheKey) + promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") if promptCacheKey == "" { - // Fingerprint convergence may inject a default key when the client did - // not send one; retain that fallback without replacing an explicit raw key. - promptCacheKey = openAIWSPayloadString(payload, "prompt_cache_key") + application, _ := businessSystemPromptApplicationFromRequest(c, BusinessSystemPromptProtocolResponses) + promptCacheKey = deriveBusinessSystemPromptCacheKey(c, strings.TrimSpace(clientPromptCacheKey), application) } _, hasTools := payload["tools"] debugEnabled := isOpenAIWSModeDebugEnabled() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index d0b55a13165e..256c338afbd1 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -901,7 +901,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, blocked.Message, blocked) } firstClientMessage = updatedFirst - beginBusinessSystemPromptRequestTurn(c) + beginBusinessSystemPromptFirstWSTurn(c) if initialRequestModel != "" { businessSystemPromptRequestSet(c, promptRequestedModelContextKey, initialRequestModel) } diff --git a/backend/internal/service/plugin_prompt_test.go b/backend/internal/service/plugin_prompt_test.go index 894c73876fa1..f3bdefb49b45 100644 --- a/backend/internal/service/plugin_prompt_test.go +++ b/backend/internal/service/plugin_prompt_test.go @@ -1,53 +1,104 @@ package service import ( + "bytes" "context" - "github.com/gin-gonic/gin" "net/http/httptest" "strings" "testing" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - policy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) -func TestPromptPluginPlanKeepsLargeClientHistoryOutOfRPC(t *testing.T) { +func TestPromptNativePlanDoesNotInvokeRPC(t *testing.T) { history := strings.Repeat("history", extensionv1.MaxPayloadBytes/7+1) body := []byte(`{"instructions":"client","input":"` + history + `"}`) - snapshot := BusinessSystemPromptSnapshot{Enabled: true, Body: "server"} + snapshot := unifiedPromptSnapshot(t, "openai", []string{"auto"}, []string{"control_append"}, []string{"server"}) invoked := 0 updated, application, err := applyBusinessSystemPromptWithInvoker(context.Background(), body, snapshot, BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}, func(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { invoked++ - require.Less(t, len(in.Payload), extensionv1.MaxPayloadBytes) - require.NotContains(t, string(in.Payload), "history") - return policy.New().Invoke(ctx, in) + t.Fatal("native planning invoked a plugin RPC") + return extensionv1.Result{}, nil }) require.NoError(t, err) - require.Equal(t, 1, invoked) + require.Equal(t, 0, invoked) require.True(t, application.Applied) require.Equal(t, "client\n\nserver", gjson.GetBytes(updated, "instructions").String()) require.Equal(t, history, gjson.GetBytes(updated, "input").String()) } -func TestPromptPluginCancellationUsesIncomingContext(t *testing.T) { +func TestPromptNativeCancellationUsesIncomingContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err := applyBusinessSystemPromptWithInvoker(ctx, []byte(`{"input":"test"}`), BusinessSystemPromptSnapshot{Enabled: true, Body: "server"}, BusinessSystemPromptTarget{Platform: PlatformOpenAI}, func(call context.Context, _, _ string, _ extensionv1.Invocation) (extensionv1.Result, error) { - require.ErrorIs(t, call.Err(), context.Canceled) - return extensionv1.Result{}, call.Err() + t.Fatal("canceled native planning invoked a plugin RPC") + return extensionv1.Result{}, nil }) require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) } -func TestPromptRetryRequiresTheOriginalValidatedSnapshot(t *testing.T) { - store := &fakeBusinessSystemPromptStore{loaded: BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "current-server"}} - policy := NewBusinessSystemPromptService(store, nil) - require.NoError(t, policy.Initialize(context.Background())) - gateway := &OpenAIGatewayService{businessPromptService: policy} - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set(businessSystemPromptRequestApplicationKey+":"+BusinessSystemPromptProtocolResponses, businessSystemPromptRequestState{application: BusinessSystemPromptApplication{Applied: true, Carrier: BusinessSystemPromptCarrierInstructions, ServerInstructions: "derived-server", Revision: 1}}) - _, _, err := gateway.applyBusinessSystemPromptForRequest(c, []byte(`{"instructions":"different-client"}`), &Account{Platform: PlatformOpenAI}, BusinessSystemPromptProtocolResponses, false) - require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable, "missing request snapshots cannot be reconstructed from result metadata") +func TestPromptSendRequiresValidatedRulesInsteadOfDerivedApplicationMetadata(t *testing.T) { + for _, initialized := range []bool{false, true} { + name := "published snapshot missing" + if initialized { + name = "published rules available" + } + t.Run(name, func(t *testing.T) { + store := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, Body: "current-server"}) + store.loaded.RulePolicy.Rules[0].Role = extensionv1.PromptRoleDeveloper + store.loaded.RulePolicy.Rules[0].Position = extensionv1.PromptPositionBeforeLastUser + policy := NewBusinessSystemPromptService(store, nil) + if initialized { + require.NoError(t, policy.Initialize(context.Background())) + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, BusinessSystemPromptProtocolResponses), businessSystemPromptRequestState{ + application: BusinessSystemPromptApplication{Applied: true, Carrier: BusinessSystemPromptCarrierInstructions, ServerInstructions: "derived-server", Revision: 1}, + }) + clean := []byte(`{"instructions":" exact client\n","input":[{"role":"user","content":[{"type":"input_text","text":"hello"},{"type":"input_image","image_url":"data:image/png;base64,AAAA"}]}]}`) + original := bytes.Clone(clean) + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body, application, err := policy.ApplyForSend(c, account, clean, BusinessSystemPromptProtocolResponses, false) + if !initialized { + require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable, "application metadata cannot provide a missing validated rule snapshot") + require.Equal(t, original, clean) + return + } + require.NoError(t, err) + require.True(t, application.Applied) + require.Len(t, application.RulesPlan.Placements, 1) + require.Equal(t, "input", application.RulesPlan.Placements[0].Carrier) + require.Equal(t, "developer", application.RulesPlan.Placements[0].Role) + require.Equal(t, " exact client\n", gjson.GetBytes(body, "instructions").String()) + require.Equal(t, "developer", gjson.GetBytes(body, "input.0.role").String()) + require.Equal(t, "current-server", gjson.GetBytes(body, "input.0.content").String()) + require.Equal(t, int64(2), gjson.GetBytes(body, "input.#").Int()) + require.JSONEq(t, gjson.GetBytes(clean, "input.0").Raw, gjson.GetBytes(body, "input.1").Raw) + require.NotContains(t, string(body), "derived-server") + require.Equal(t, original, clean) + + next := businessSystemPromptStoreWithV2Rules(t, BusinessSystemPromptSnapshot{Revision: 2, VersionID: 2, Enabled: true, Body: "replacement-server"}) + next.loaded.RulePolicy.Rules[0].Role = extensionv1.PromptRoleDeveloper + next.loaded.RulePolicy.Rules[0].Position = extensionv1.PromptPositionBeforeLastUser + store.loaded, store.detail = next.loaded, next.detail + require.NoError(t, policy.Reload(context.Background())) + retry, retried, err := policy.ApplyForSend(c, account, clean, BusinessSystemPromptProtocolResponses, false) + require.NoError(t, err) + require.Equal(t, body, retry) + require.Equal(t, int64(1), retried.Revision) + require.Equal(t, application.RulesPlan.SHA256, retried.RulesPlan.SHA256) + + disabled := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Extra: map[string]any{ + PromptAccountBindingExtraKey: map[string]any{"mode": "off", "rule_ids": []string{}}, + }} + unmodified, skipped, err := policy.ApplyForSend(c, disabled, clean, BusinessSystemPromptProtocolResponses, false) + require.NoError(t, err) + require.False(t, skipped.Applied) + require.Equal(t, original, unmodified) + require.Equal(t, []extensionv1.PromptRuleDecision{{RuleID: "fixture", Reason: "account_scope"}}, skipped.RulesPlan.Skipped) + }) + } } diff --git a/backend/internal/service/prompt_config.go b/backend/internal/service/prompt_config.go new file mode 100644 index 000000000000..6e22677f8e22 --- /dev/null +++ b/backend/internal/service/prompt_config.go @@ -0,0 +1,295 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "slices" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + promptpolicy "github.com/Wei-Shaw/sub2api/internal/promptskills/policy" +) + +// 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"` +} + +type PromptConfigUpdate struct { + ExpectedRevision int64 `json:"expected_revision"` + Enabled bool `json:"enabled"` + ExposeServerPrompt bool `json:"expose_server_prompt"` + CompactEnabled bool `json:"compact_enabled"` + Policy extensionv1.PromptRulePolicy `json:"policy"` + Contents map[string]PromptContentDraft `json:"contents"` +} + +type PromptConfigContent struct { + Body string `json:"body"` + CompositionMode string `json:"composition_mode"` + Managed bool `json:"managed"` + TemplateID int64 `json:"template_id"` + VersionID int64 `json:"version_id"` + Available bool `json:"available"` +} + +type PromptConfigState struct { + Revision int64 `json:"revision"` + Enabled bool `json:"enabled"` + ExposeServerPrompt bool `json:"expose_server_prompt"` + CompactEnabled bool `json:"compact_enabled"` + Policy extensionv1.PromptRulePolicy `json:"policy"` + Contents map[string]PromptConfigContent `json:"contents"` + Capabilities []extensionv1.PromptProtocolCapability `json:"capabilities"` +} + +type PromptConfigStore interface { + SavePromptConfig(context.Context, PromptConfigUpdate, int64) error +} + +func (s *BusinessSystemPromptService) PublishVersionToRules(ctx context.Context, templateID, versionID, expectedRevision int64, ruleIDs []string, action string, actorID int64) (PromptConfigState, error) { + if len(ruleIDs) == 0 || (action != "publish" && action != "rollback") { + return PromptConfigState{}, fmt.Errorf("%w: select the rules to update", ErrBusinessSystemPromptInvalid) + } + state, err := s.PromptConfig(ctx) + if err != nil { + return PromptConfigState{}, err + } + if state.Revision != expectedRevision { + return PromptConfigState{}, ErrBusinessSystemPromptRevisionConflict + } + state.Policy.Rules = slices.Clone(state.Policy.Rules) + seen := make(map[string]bool, len(ruleIDs)) + for _, id := range ruleIDs { + index := slices.IndexFunc(state.Policy.Rules, func(rule extensionv1.PromptRule) bool { return rule.ID == id }) + if index < 0 || seen[id] || state.Policy.Rules[index].TemplateID != templateID { + return PromptConfigState{}, ErrBusinessSystemPromptInvalid + } + seen[id] = true + state.Policy.Rules[index].VersionID = versionID + } + return s.SavePromptConfig(ctx, PromptConfigUpdate{ + ExpectedRevision: expectedRevision, Enabled: state.Enabled, ExposeServerPrompt: state.ExposeServerPrompt, + CompactEnabled: state.CompactEnabled, Policy: state.Policy, + }, actorID) +} + +func supportsPromptAccount(account *Account) bool { + return account != nil && slices.Contains(promptpolicy.PromptPlatforms(), account.Platform) +} + +func (s *BusinessSystemPromptService) PromptConfig(ctx context.Context) (PromptConfigState, error) { + snapshot, ok := s.CurrentSnapshot() + if !ok || snapshot.RulePolicy == nil { + return PromptConfigState{}, ErrBusinessSystemPromptUnavailable + } + state := PromptConfigState{ + Revision: snapshot.Revision, Enabled: snapshot.Enabled, + ExposeServerPrompt: snapshot.ExposeServerPrompt, CompactEnabled: snapshot.CompactEnabled, + Policy: *snapshot.RulePolicy, Contents: make(map[string]PromptConfigContent), + Capabilities: promptpolicy.PromptProtocolCapabilities(), + } + details := make(map[int64]BusinessSystemPromptTemplateDetail) + var publication *RemoteSkillPublication + if s.registry != nil { + publication = s.registry.publication.Load() + } + for _, rule := range state.Policy.Rules { + detail, exists := details[rule.TemplateID] + if !exists { + var err error + detail, err = s.store.GetBusinessSystemPromptTemplate(ctx, rule.TemplateID) + if err != nil { + return PromptConfigState{}, err + } + details[rule.TemplateID] = detail + } + index := slices.IndexFunc(detail.Versions, func(version BusinessSystemPromptVersion) bool { return version.ID == rule.VersionID }) + if index < 0 { + return PromptConfigState{}, ErrBusinessSystemPromptVersionNotFound + } + version := detail.Versions[index] + body := version.Body + available := true + if version.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { + body, available = "", false + if publication != nil { + body, available = publication.EffectivePromptBody, true + } + } + state.Contents[rule.ID] = PromptConfigContent{ + Body: body, CompositionMode: version.CompositionMode, + Managed: detail.Template.ManagedSource != "" || version.CompositionMode != BusinessSystemPromptCompositionInline, + TemplateID: rule.TemplateID, VersionID: rule.VersionID, Available: available, + } + } + return state, nil +} + +func (s *BusinessSystemPromptService) SavePromptConfig(ctx context.Context, input PromptConfigUpdate, actorID int64) (PromptConfigState, error) { + store, ok := s.store.(PromptConfigStore) + if !ok { + return PromptConfigState{}, ErrBusinessSystemPromptUnavailable + } + current, ok := s.CurrentSnapshot() + if !ok || current.RulePolicy == nil { + return PromptConfigState{}, ErrBusinessSystemPromptUnavailable + } + if input.ExpectedRevision < 1 || current.Revision != input.ExpectedRevision { + return PromptConfigState{}, ErrBusinessSystemPromptRevisionConflict + } + 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 { + return PromptConfigState{}, err + } + input.Policy = *current.RulePolicy + if err := store.SavePromptConfig(ctx, input, actorID); err != nil { + return PromptConfigState{}, err + } + if err := s.Reload(ctx); err != nil { + return PromptConfigState{}, err + } + state, err := s.PromptConfig(ctx) + if err == nil && s.bus != nil { + if publishErr := s.bus.Publish(ctx, state.Revision); publishErr != nil { + _ = s.retainLastGood(publishErr) + } + } + return state, err +} + +// Draft compilation is also used by preview, so unsaved text follows exactly +// the same content validation and source resolution as a published rule. +func (s *BusinessSystemPromptService) preparePromptRulesDraft(ctx context.Context, snapshot *BusinessSystemPromptSnapshot, drafts map[string]PromptContentDraft) error { + return s.compilePromptRules(ctx, snapshot, drafts, true) +} + +func (s *BusinessSystemPromptService) compilePromptRules(ctx context.Context, snapshot *BusinessSystemPromptSnapshot, drafts map[string]PromptContentDraft, strictSources bool) error { + if snapshot.RulePolicy == nil { + return ErrBusinessSystemPromptUnavailable + } + draftIDs := make([]string, 0, len(drafts)) + for id := range drafts { + if !slices.ContainsFunc(snapshot.RulePolicy.Rules, func(rule extensionv1.PromptRule) bool { return rule.ID == id }) { + return fmt.Errorf("%w: content references an unknown rule", ErrBusinessSystemPromptInvalid) + } + draftIDs = append(draftIDs, id) + } + policy, err := promptpolicy.ValidateRulePolicyDraft(*snapshot.RulePolicy, draftIDs) + if err != nil { + return fmt.Errorf("%w: %v", ErrBusinessSystemPromptInvalid, err) + } + snapshot.RulePolicy = &policy + snapshot.Draft = len(drafts) > 0 + snapshot.ResolvedRules = nil + snapshot.RegistryRevision = 0 + // The publication is immutable. Capture it once before resolving any rule + // so a concurrent source publish cannot mix generations in one request. + var publication *RemoteSkillPublication + registryDegraded := false + if s.registry != nil { + publication = s.registry.publication.Load() + registryDegraded = s.registry.CurrentSnapshot().Degraded + } + for _, rule := range policy.Rules { + var content BusinessSystemPromptSnapshot + if draft, edited := drafts[rule.ID]; edited { + if rule.TemplateID > 0 { + detail, err := s.store.GetBusinessSystemPromptTemplate(ctx, rule.TemplateID) + if err != nil { + return err + } + index := slices.IndexFunc(detail.Versions, func(version BusinessSystemPromptVersion) bool { return version.ID == rule.VersionID }) + if index < 0 { + return ErrBusinessSystemPromptVersionNotFound + } + if detail.Template.ManagedSource != "" || detail.Versions[index].CompositionMode != BusinessSystemPromptCompositionInline { + return ErrBusinessSystemPromptSourceNotManaged + } + } + hash, size, err := ValidateBusinessSystemPromptBody(draft.Body) + if err != nil { + return err + } + content = BusinessSystemPromptSnapshot{Enabled: true, Body: draft.Body, SHA256: hash, ByteLength: size, CompositionMode: BusinessSystemPromptCompositionInline} + } else { + detail, err := s.store.GetBusinessSystemPromptTemplate(ctx, rule.TemplateID) + if err != nil { + return err + } + index := slices.IndexFunc(detail.Versions, func(version BusinessSystemPromptVersion) bool { return version.ID == rule.VersionID }) + if index < 0 { + return ErrBusinessSystemPromptVersionNotFound + } + version := detail.Versions[index] + 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) + } + if !rule.Enabled { + continue + } + if content.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid && !snapshot.Enabled { + // Turning off prompt serving must work even while its optional + // source is unavailable. Enabling or simulating compiles it again. + continue + } + if content.CompositionMode == extensionv1.PromptContentAnthropicSystemBlocks { + if _, err := parseClaudeOAuthSystemPromptBlocksConfig(content.Body); err != nil { + return fmt.Errorf("%w: invalid system blocks", ErrBusinessSystemPromptInvalid) + } + } else if content.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid { + content, err = compilePromptPublication(content, publication) + if err != nil && !strictSources { + snapshot.Degraded = true + snapshot.ResolvedRules = append(snapshot.ResolvedRules, extensionv1.ResolvedPromptRule{Rule: rule, Unavailable: true}) + continue + } + if err != nil { + return err + } + snapshot.RegistryRevision = publication.Revision + snapshot.BundleDegraded = registryDegraded + snapshot.Degraded = snapshot.Degraded || registryDegraded + } else { + err = s.prepareBusinessSystemPromptSnapshot(&content) + if err == nil { + content, err = s.compileBusinessSystemPromptSnapshot(content) + } + if err != nil { + return err + } + } + } + hash, _, err := validateBusinessSystemPromptBodyWithLimit(content.Body, extensionv1.PromptRulesMaxBytes) + if err != nil { + return err + } + if !rule.Enabled { + continue + } + resolved := extensionv1.ResolvedPromptRule{Rule: rule, Body: content.Body, SHA256: hash, PreserveEcho: content.CompositionMode == BusinessSystemPromptCompositionCodexSkillHybrid} + if content.CompositionMode == extensionv1.PromptContentAnthropicSystemBlocks { + resolved.ContentFormat = content.CompositionMode + resolved.StructuredContent = json.RawMessage(content.Body) + } + snapshot.ResolvedRules = append(snapshot.ResolvedRules, resolved) + } + return nil +} + +func validStructuredPromptScope(rule extensionv1.PromptRule) bool { + if (rule.Role != "auto" && rule.Role != "system") || (rule.Position != "control_prepend" && rule.Position != "control_append") || + len(rule.Platforms) != 1 || rule.Platforms[0] != PlatformAnthropic || len(rule.AccountTypes) == 0 || + len(rule.RequestProfiles) != 1 || rule.RequestProfiles[0] != "generic-mimic" { + return false + } + for _, accountType := range rule.AccountTypes { + if accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken { + return false + } + } + return true +} diff --git a/backend/internal/service/prompt_config_generation_test.go b/backend/internal/service/prompt_config_generation_test.go new file mode 100644 index 000000000000..b0b8d8037b92 --- /dev/null +++ b/backend/internal/service/prompt_config_generation_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type changingPromptPublicationStore struct { + fakeBusinessSystemPromptStore + onRead func() +} + +func (s *changingPromptPublicationStore) GetBusinessSystemPromptTemplate(_ context.Context, id int64) (BusinessSystemPromptTemplateDetail, error) { + if s.onRead != nil { + s.onRead() + } + detail := s.detail + detail.Template.ID = id + detail.Versions = append([]BusinessSystemPromptVersion(nil), detail.Versions...) + for i := range detail.Versions { + detail.Versions[i].TemplateID = id + } + return detail, nil +} + +func TestPromptConfigCompilesOnePublicationGeneration(t *testing.T) { + registry := &RemoteSkillRegistryService{} + publish := func(revision int64, body string) { + t.Helper() + hash := hashBusinessSystemPromptBundleBytes([]byte(body)) + require.NoError(t, registry.installPublication(RemoteSkillPublication{ + Revision: revision, CandidateID: revision, EffectiveTreeSHA256: strings.Repeat("a", 64), + EffectivePromptSHA256: hash, EffectivePromptBody: body, + Files: map[string][]byte{"SKILL.md": []byte("fixture")}, Prompt: RemoteSkillPromptVersion{ID: revision, EffectiveSHA256: hash}, + })) + } + publish(1, "first publication") + baseHash, baseSize, err := ValidateBusinessSystemPromptBody("base") + require.NoError(t, err) + store := &changingPromptPublicationStore{} + store.detail = BusinessSystemPromptTemplateDetail{Versions: []BusinessSystemPromptVersion{{ + ID: 2, Body: "base", SHA256: baseHash, ByteLength: baseSize, + CompositionMode: BusinessSystemPromptCompositionCodexSkillHybrid, BundleID: BusinessSystemPromptRemoteSkillBundleID, + }}} + store.loaded = BusinessSystemPromptSnapshot{Revision: 7, Enabled: true, RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, DefaultRuleIDs: []string{"one", "two"}, Rules: []extensionv1.PromptRule{ + {ID: "one", Name: "One", Enabled: true, TemplateID: 1, VersionID: 2, Role: "auto", Platforms: []string{"openai"}, Position: "control_append"}, + {ID: "two", Name: "Two", Enabled: true, TemplateID: 3, VersionID: 2, Role: "auto", Platforms: []string{"openai"}, Position: "control_append"}, + }}} + reads := 0 + store.onRead = func() { + reads++ + if reads == 2 { + publish(2, "second publication") + } + } + svc := NewBusinessSystemPromptService(store, nil) + svc.registry = registry + require.NoError(t, svc.Reload(context.Background())) + first, _ := svc.CurrentSnapshot() + require.Equal(t, int64(1), first.RegistryRevision) + for _, rule := range first.ResolvedRules { + require.Equal(t, "first publication", rule.Body) + } + store.onRead = nil + require.NoError(t, svc.Reload(context.Background())) + second, _ := svc.CurrentSnapshot() + require.Equal(t, int64(2), second.RegistryRevision) + for _, rule := range second.ResolvedRules { + require.Equal(t, "second publication", rule.Body) + } + svc.installBusinessSystemPromptSnapshot(first) + current, _ := svc.CurrentSnapshot() + require.Equal(t, int64(2), current.RegistryRevision, "late compilation of the same configuration cannot rewind its source") + reads = 0 + store.onRead = func() { + reads++ + if reads == 2 { + publish(3, "third publication") + } + } + config, err := svc.PromptConfig(context.Background()) + require.NoError(t, err) + for _, content := range config.Contents { + require.Equal(t, "second publication", content.Body) + } +} + +func TestPromptConfigBudgetsOnlySelectedPlatformContent(t *testing.T) { + store := &promptConfigMemoryStore{} + body := strings.Repeat("x", 8<<10) + hash, size, err := ValidateBusinessSystemPromptBody(body) + require.NoError(t, err) + store.detail = BusinessSystemPromptTemplateDetail{Template: BusinessSystemPromptTemplate{ID: 1}, Versions: []BusinessSystemPromptVersion{{ID: 2, TemplateID: 1, Body: body, SHA256: hash, ByteLength: size, CompositionMode: "inline"}}} + policy := extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{}, DefaultRuleIDs: []string{}} + for i := 0; i < 33; i++ { + platform := "openai" + if i == 32 { + platform = "anthropic" + } + id := fmt.Sprintf("rule-%d", i) + policy.Rules = append(policy.Rules, extensionv1.PromptRule{ID: id, Name: id, Enabled: true, TemplateID: 1, VersionID: 2, Role: "auto", Position: "control_append", Platforms: []string{platform}}) + policy.DefaultRuleIDs = append(policy.DefaultRuleIDs, id) + } + svc := NewBusinessSystemPromptService(store, nil) + snapshot := BusinessSystemPromptSnapshot{Revision: 1, Enabled: true, RulePolicy: &policy} + require.NoError(t, svc.preparePromptRulesDraft(context.Background(), &snapshot, nil), "the union of independent provider rules is valid") + _, application, err := ApplyBusinessSystemPromptToJSON([]byte(`{"input":"question"}`), snapshot, BusinessSystemPromptTarget{Platform: "openai", ProviderPlatform: "openai", Protocol: "responses", AccountType: "apikey"}) + require.NoError(t, err) + require.Len(t, application.RulesPlan.Placements, 32) + policy.Rules[32].Platforms = []string{"openai"} + snapshot.RulePolicy = &policy + require.NoError(t, svc.preparePromptRulesDraft(context.Background(), &snapshot, nil)) + _, _, err = ApplyBusinessSystemPromptToJSON([]byte(`{"input":"question"}`), snapshot, BusinessSystemPromptTarget{Platform: "openai", ProviderPlatform: "openai", Protocol: "responses", AccountType: "apikey"}) + require.Error(t, err, "a single actual request still enforces 256 KiB") +} + +func TestPromptConfigGeminiPreviewUsesURLModelMapping(t *testing.T) { + snapshot := unifiedPromptSnapshot(t, PlatformGemini, []string{"auto"}, []string{"control_append"}, []string{"site instruction"}) + snapshot.RulePolicy.Rules[0].Models = []string{"gemini-2.5-pro"} + snapshot.ResolvedRules[0].Rule = snapshot.RulePolicy.Rules[0] + svc := NewBusinessSystemPromptService(nil, nil) + svc.snapshot.Store(&snapshot) + account := &Account{ID: 83, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"client-alias": "gemini-2.5-pro"}}} + svc.accountRepo = &promptRulesPreviewAccountRepo{account: account} + result, err := svc.PreviewPromptRules(context.Background(), account.ID, PromptRulesPreviewRequest{Protocol: "gemini", Body: json.RawMessage(`{"model":"client-alias","contents":[{"role":"user","parts":[{"text":"question"}]}]}`)}) + require.NoError(t, err) + require.Equal(t, "client-alias", result.RequestedModel) + require.Equal(t, "gemini-2.5-pro", result.UpstreamModel) + require.True(t, result.Application.Applied) + require.Equal(t, "site instruction", gjson.GetBytes(result.Body, "systemInstruction.parts.0.text").String()) +} diff --git a/backend/internal/service/prompt_config_test.go b/backend/internal/service/prompt_config_test.go new file mode 100644 index 000000000000..f41041a12760 --- /dev/null +++ b/backend/internal/service/prompt_config_test.go @@ -0,0 +1,141 @@ +package service + +import ( + "context" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type promptConfigMemoryStore struct { + fakeBusinessSystemPromptStore + saves int + last PromptConfigUpdate +} + +func (f *promptConfigMemoryStore) SavePromptConfig(_ context.Context, input PromptConfigUpdate, _ int64) error { + f.saves++ + f.last = input + policy := input.Policy + policy.Rules = append([]extensionv1.PromptRule(nil), input.Policy.Rules...) + for i := range policy.Rules { + rule := &policy.Rules[i] + if draft, ok := input.Contents[rule.ID]; ok { + rule.TemplateID, rule.VersionID = 11, 12 + hash, size, err := ValidateBusinessSystemPromptBody(draft.Body) + if err != nil { + return err + } + f.detail = BusinessSystemPromptTemplateDetail{ + Template: BusinessSystemPromptTemplate{ID: 11}, + Versions: []BusinessSystemPromptVersion{{ID: 12, TemplateID: 11, Version: 1, Body: draft.Body, SHA256: hash, ByteLength: size, CompositionMode: BusinessSystemPromptCompositionInline}}, + } + } + } + f.loaded = BusinessSystemPromptSnapshot{Revision: input.ExpectedRevision + 1, Enabled: input.Enabled, + ExposeServerPrompt: input.ExposeServerPrompt, CompactEnabled: input.CompactEnabled, RulePolicy: &policy} + return nil +} + +func newPromptConfigService(t *testing.T) (*BusinessSystemPromptService, *promptConfigMemoryStore, *fakeBusinessSystemPromptBus, PromptConfigUpdate) { + t.Helper() + store := &promptConfigMemoryStore{} + store.loaded = BusinessSystemPromptSnapshot{Revision: 4, Enabled: true, + RulePolicy: &extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{}, DefaultRuleIDs: []string{}}} + bus := &fakeBusinessSystemPromptBus{} + svc := NewBusinessSystemPromptService(store, bus) + require.NoError(t, svc.Reload(context.Background())) + rule := extensionv1.PromptRule{ID: "new", Name: "New", Enabled: true, Role: "developer", Position: "before_last_user", Platforms: []string{PlatformOpenAI}, ModelMatch: "upstream", Models: []string{}} + input := PromptConfigUpdate{ExpectedRevision: 4, Enabled: true, + Policy: extensionv1.PromptRulePolicy{Version: 2, Rules: []extensionv1.PromptRule{rule}, DefaultRuleIDs: []string{"new"}}, + Contents: map[string]PromptContentDraft{"new": {Body: "unsaved instruction"}}, + } + return svc, store, bus, input +} + +func TestPromptConfigSavesContentAndRulesTogether(t *testing.T) { + svc, store, bus, input := newPromptConfigService(t) + state, err := svc.SavePromptConfig(context.Background(), input, 7) + require.NoError(t, err) + require.Equal(t, 1, store.saves) + require.Equal(t, []int64{5}, bus.revisions) + require.Equal(t, int64(5), state.Revision) + require.Equal(t, int64(12), state.Policy.Rules[0].VersionID) + require.Equal(t, "unsaved instruction", state.Contents["new"].Body) + require.NotEmpty(t, state.Capabilities) + snapshot, ok := svc.CurrentSnapshot() + require.True(t, ok) + require.False(t, snapshot.Draft) + output, application, err := ApplyBusinessSystemPromptToJSON([]byte(`{"model":"gpt-test","input":"question"}`), snapshot, + BusinessSystemPromptTarget{Platform: PlatformOpenAI, ProviderPlatform: PlatformOpenAI, Protocol: "responses", AccountType: AccountTypeAPIKey}) + require.NoError(t, err) + require.True(t, application.Applied) + require.Equal(t, "developer", gjson.GetBytes(output, "input.0.role").String()) + require.Equal(t, "unsaved instruction", gjson.GetBytes(output, "input.0.content").String()) +} + +func TestPromptConfigDraftAndRejectedEditsDoNotWrite(t *testing.T) { + svc, store, bus, input := newPromptConfigService(t) + snapshot, _ := svc.CurrentSnapshot() + snapshot.RulePolicy = &input.Policy + require.NoError(t, svc.preparePromptRulesDraft(context.Background(), &snapshot, input.Contents)) + output, _, err := ApplyBusinessSystemPromptToJSON([]byte(`{"input":"question"}`), snapshot, + BusinessSystemPromptTarget{Platform: PlatformOpenAI, ProviderPlatform: PlatformOpenAI, Protocol: "responses", AccountType: AccountTypeAPIKey}) + require.NoError(t, err) + require.Equal(t, "unsaved instruction", gjson.GetBytes(output, "input.0.content").String()) + require.Zero(t, store.saves) + current, _ := svc.CurrentSnapshot() + require.Empty(t, current.RulePolicy.Rules) + input.ExpectedRevision = 3 + _, err = svc.SavePromptConfig(context.Background(), input, 7) + require.ErrorIs(t, err, ErrBusinessSystemPromptRevisionConflict) + input.ExpectedRevision = 4 + input.Contents["unknown"] = PromptContentDraft{Body: "not attached to a rule"} + _, err = svc.SavePromptConfig(context.Background(), input, 7) + require.ErrorIs(t, err, ErrBusinessSystemPromptInvalid) + require.Zero(t, store.saves) + require.Empty(t, bus.revisions) +} + +func TestPromptConfigInlineRuntimeStartsWithoutSkillRegistry(t *testing.T) { + svc, _, _, _ := newPromptConfigService(t) + svc.bus = nil + runtime := NewPromptDomainRuntime(nil, svc) + runtime.Start(context.Background()) + t.Cleanup(runtime.Stop) + require.True(t, runtime.ready) + snapshot, ok := svc.CurrentSnapshot() + require.True(t, ok) + require.NotNil(t, snapshot.RulePolicy) +} + +func TestPromptConfigSourceFailureDoesNotPreventDisabling(t *testing.T) { + svc, store, _, input := newPromptConfigService(t) + _, err := svc.SavePromptConfig(context.Background(), input, 7) + require.NoError(t, err) + version := store.detail.Versions[0] + version.ID = 13 + version.CompositionMode = BusinessSystemPromptCompositionCodexSkillHybrid + version.BundleID = BusinessSystemPromptRemoteSkillBundleID + store.detail.Versions = append(store.detail.Versions, version) + rule := store.loaded.RulePolicy.Rules[0] + rule.ID, rule.VersionID, rule.Position, rule.Role = "remote", 13, "control_append", "auto" + store.loaded.RulePolicy.Rules = append(store.loaded.RulePolicy.Rules, rule) + // Loading a missing optional source keeps unrelated inline rules available. + require.NoError(t, svc.Reload(context.Background())) + snapshot, _ := svc.CurrentSnapshot() + require.True(t, snapshot.Degraded) + output, application, err := ApplyBusinessSystemPromptToJSON([]byte(`{"input":"question"}`), snapshot, + BusinessSystemPromptTarget{Platform: PlatformOpenAI, ProviderPlatform: PlatformOpenAI, Protocol: "responses", AccountType: AccountTypeAPIKey}) + require.NoError(t, err) + require.True(t, application.Applied) + require.Equal(t, "unsaved instruction", gjson.GetBytes(output, "input.0.content").String()) + state, err := svc.SavePromptConfig(context.Background(), PromptConfigUpdate{ + ExpectedRevision: snapshot.Revision, Policy: *snapshot.RulePolicy, Enabled: false, + }, 7) + require.NoError(t, err) + require.False(t, state.Enabled) + require.False(t, state.Contents["remote"].Available) +} diff --git a/backend/internal/service/prompt_domain_runtime.go b/backend/internal/service/prompt_domain_runtime.go index 8f24598abeff..84cb5322b2bd 100644 --- a/backend/internal/service/prompt_domain_runtime.go +++ b/backend/internal/service/prompt_domain_runtime.go @@ -10,17 +10,19 @@ import ( extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -// Initialize domain storage only after the embedded skill registry is ready. -// Existing state stays available for history otherwise. +// Inline prompt serving is independent of the optional paired Skill source. +// Each service starts once; an unavailable registry is retried independently. type PromptDomainRuntime struct { - registry *RemoteSkillRegistryService - prompts *BusinessSystemPromptService - mu sync.Mutex - reconcileMu sync.Mutex - cancel context.CancelFunc - done chan struct{} - ready bool - nextRetry time.Time + 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 } func NewPromptDomainRuntime(registry *RemoteSkillRegistryService, prompts *BusinessSystemPromptService) *PromptDomainRuntime { @@ -55,12 +57,6 @@ func (r *PromptDomainRuntime) Start(parent context.Context) { ctx, cancel := context.WithCancel(parent) r.cancel, r.done = cancel, make(chan struct{}) r.mu.Unlock() - // Read persisted intent only when the policy process is unavailable. On the - // healthy path, BusinessSystemPromptService.Start initializes and reloads it - // exactly once after the registry is ready. - if promptPolicyAvailability(ctx) != nil && r.prompts != nil { - _ = r.prompts.Reload(ctx) - } r.reconcile(ctx, time.Now()) go func() { defer close(r.done) @@ -83,47 +79,44 @@ func (r *PromptDomainRuntime) reconcile(ctx context.Context, now time.Time) { } r.reconcileMu.Lock() defer r.reconcileMu.Unlock() - if promptPolicyAvailability(ctx) != nil { - r.stopDomainServices() - r.mu.Lock() - r.ready = false - r.nextRetry = time.Time{} - r.mu.Unlock() - return - } r.mu.Lock() ready, nextRetry := r.ready, r.nextRetry r.mu.Unlock() if ready || now.Before(nextRetry) { return } - if r.registry == nil || r.prompts == nil { + if r.prompts == nil { r.mu.Lock() r.nextRetry = now.Add(10 * time.Second) r.mu.Unlock() return } - if err := r.registry.Start(ctx); err != nil { - // Keep the persisted prompt snapshot available if registry startup fails, - // but do not start any new plugin-backed work. - _ = r.prompts.Reload(ctx) - r.mu.Lock() - r.nextRetry = now.Add(10 * time.Second) - r.mu.Unlock() - slog.Warn("prompt_domain_initialization_unavailable", "component", "registry") - 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 err := r.prompts.Start(ctx); err != nil { - r.registry.Stop() - r.mu.Lock() - r.nextRetry = now.Add(10 * time.Second) - r.mu.Unlock() - slog.Warn("prompt_domain_initialization_unavailable", "component", "prompts") - return + if !r.promptsReady { + if err := r.prompts.Start(ctx); err != nil { + slog.Warn("prompt_domain_initialization_unavailable", "component", "prompts") + } else { + r.promptsReady = true + } } r.mu.Lock() - r.ready = true - r.nextRetry = time.Time{} + r.ready = r.promptsReady && r.registryReady + if r.ready { + r.nextRetry = time.Time{} + } else { + r.nextRetry = now.Add(10 * time.Second) + } r.mu.Unlock() } @@ -140,6 +133,8 @@ func (r *PromptDomainRuntime) stopDomainServices() { if r.registry != nil { r.registry.Stop() } + r.promptsReady = false + r.registryReady = false } func (r *PromptDomainRuntime) Stop() { diff --git a/backend/internal/service/remote_skill_registry.go b/backend/internal/service/remote_skill_registry.go index a7985b98e99f..2b712949eb02 100644 --- a/backend/internal/service/remote_skill_registry.go +++ b/backend/internal/service/remote_skill_registry.go @@ -397,6 +397,25 @@ func (s *RemoteSkillRegistryService) PublishVersion(ctx context.Context, version } func (s *RemoteSkillRegistryService) PublishVersionAction(ctx context.Context, versionID, expectedRevision int64, action string, actorID int64) (RemoteSkillRegistrySnapshot, error) { + return s.publishVersionAction(ctx, versionID, expectedRevision, action, actorID, nil) +} + +// PromptSourceTargets names every rule subscribed to the shared paired source. +// Its public /current tree and prompt are always published together. +type PromptSourceTargets struct { + ExpectedRevision int64 + RuleIDs []string +} + +type RemoteSkillPromptPublicationStore interface { + PublishRemoteSkillVersionWithPromptTargets(context.Context, int64, int64, int64, PromptSourceTargets) (RemoteSkillRegistrySnapshot, error) +} + +func (s *RemoteSkillRegistryService) PublishVersionForRules(ctx context.Context, versionID, expectedRevision int64, action string, actorID int64, targets PromptSourceTargets) (RemoteSkillRegistrySnapshot, error) { + return s.publishVersionAction(ctx, versionID, expectedRevision, action, actorID, &targets) +} + +func (s *RemoteSkillRegistryService) publishVersionAction(ctx context.Context, versionID, expectedRevision int64, action string, actorID int64, targets *PromptSourceTargets) (RemoteSkillRegistrySnapshot, error) { s.applyMu.Lock() defer s.applyMu.Unlock() detail, err := s.store.GetRemoteSkillVersion(ctx, versionID) @@ -441,7 +460,16 @@ func (s *RemoteSkillRegistryService) PublishVersionAction(ctx context.Context, v if err != nil { return RemoteSkillRegistrySnapshot{}, fmt.Errorf("%w: paired candidate validation failed", ErrBusinessSystemPromptUnavailable) } - snapshot, err := s.store.PublishRemoteSkillVersion(ctx, versionID, expectedRevision, actorID) + var snapshot RemoteSkillRegistrySnapshot + if targets != nil { + store, ok := s.store.(RemoteSkillPromptPublicationStore) + if !ok { + return RemoteSkillRegistrySnapshot{}, ErrBusinessSystemPromptUnavailable + } + snapshot, err = store.PublishRemoteSkillVersionWithPromptTargets(ctx, versionID, expectedRevision, actorID, *targets) + } else { + snapshot, err = s.store.PublishRemoteSkillVersion(ctx, versionID, expectedRevision, actorID) + } if err != nil { return RemoteSkillRegistrySnapshot{}, err } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index acf87a88af78..912a45677ca7 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -58,10 +58,12 @@ func ProvideBusinessSystemPromptService( remoteSkillRegistryBus RemoteSkillRegistryRevisionBus, accountRepo AccountRepository, cfg *config.Config, + settings *SettingService, ) (*BusinessSystemPromptService, error) { svc := NewBusinessSystemPromptService(store, bus) svc.SetAccountRepository(accountRepo) svc.previewConfig = cfg + svc.previewSettings = settings svc.SetRemoteSkillRegistryService(remoteSkillRegistry) svc.SetRemoteSkillRegistryRevisionBus(remoteSkillRegistryBus) return svc, nil @@ -124,6 +126,75 @@ func ProvideOpenAIGatewayService( return svc } +func ProvideGatewayService( + accountRepo AccountRepository, + groupRepo GroupRepository, + usageLogRepo UsageLogRepository, + usageBillingRepo UsageBillingRepository, + userRepo UserRepository, + userSubRepo UserSubscriptionRepository, + userGroupRateRepo UserGroupRateRepository, + cache GatewayCache, + cfg *config.Config, + schedulerSnapshot *SchedulerSnapshotService, + concurrencyService *ConcurrencyService, + billingService *BillingService, + rateLimitService *RateLimitService, + billingCacheService *BillingCacheService, + identityService *IdentityService, + httpUpstream HTTPUpstream, + deferredService *DeferredService, + claudeTokenProvider *ClaudeTokenProvider, + sessionLimitCache SessionLimitCache, + rpmCache RPMCache, + digestStore *DigestSessionStore, + settingService *SettingService, + tlsFPProfileService *TLSFingerprintProfileService, + channelService *ChannelService, + resolver *ModelPricingResolver, + compositeResolver *CompositeRouteResolver, + balanceNotifyService *BalanceNotifyService, + userPlatformQuotaRepo UserPlatformQuotaRepository, + businessPromptService *BusinessSystemPromptService, +) *GatewayService { + svc := NewGatewayService(accountRepo, groupRepo, usageLogRepo, usageBillingRepo, userRepo, userSubRepo, userGroupRateRepo, cache, cfg, schedulerSnapshot, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestStore, settingService, tlsFPProfileService, channelService, resolver, compositeResolver, balanceNotifyService, userPlatformQuotaRepo) + svc.SetBusinessSystemPromptService(businessPromptService) + return svc +} + +func ProvideGeminiMessagesCompatService( + accountRepo AccountRepository, + groupRepo GroupRepository, + cache GatewayCache, + schedulerSnapshot *SchedulerSnapshotService, + tokenProvider *GeminiTokenProvider, + rateLimitService *RateLimitService, + httpUpstream HTTPUpstream, + antigravityGatewayService *AntigravityGatewayService, + cfg *config.Config, + businessPromptService *BusinessSystemPromptService, +) *GeminiMessagesCompatService { + svc := NewGeminiMessagesCompatService(accountRepo, groupRepo, cache, schedulerSnapshot, tokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, cfg) + svc.SetBusinessSystemPromptService(businessPromptService) + return svc +} + +func ProvideAntigravityGatewayService( + accountRepo AccountRepository, + cache GatewayCache, + schedulerSnapshot *SchedulerSnapshotService, + tokenProvider *AntigravityTokenProvider, + rateLimitService *RateLimitService, + httpUpstream HTTPUpstream, + settingService *SettingService, + internal500Cache Internal500CounterCache, + businessPromptService *BusinessSystemPromptService, +) *AntigravityGatewayService { + svc := NewAntigravityGatewayService(accountRepo, cache, schedulerSnapshot, tokenProvider, rateLimitService, httpUpstream, settingService, internal500Cache) + svc.SetBusinessSystemPromptService(businessPromptService) + return svc +} + func ProvideCindyHealthService( accountRepo AccountRepository, identityRepo AccountCredentialIdentityRepository, @@ -1012,7 +1083,7 @@ var ProviderSet = wire.NewSet( ProvideBillingCacheService, NewAnnouncementService, NewAdminService, - NewGatewayService, + ProvideGatewayService, ProvideBusinessSystemPromptService, ProvideRemoteSkillRegistryFiles, ProvideRemoteSkillCandidateSource, @@ -1042,7 +1113,7 @@ var ProviderSet = wire.NewSet( NewAntigravityOAuthService, ProvideOAuthRefreshAPI, ProvideGeminiTokenProvider, - NewGeminiMessagesCompatService, + ProvideGeminiMessagesCompatService, ProvideAntigravityTokenProvider, ProvideGrokTokenProvider, ProvideOpenAITokenProvider, @@ -1054,7 +1125,7 @@ var ProviderSet = wire.NewSet( ProvideCNProviderBalanceService, ProvideCNProviderBalanceCheckService, ProvideClaudeTokenProvider, - NewAntigravityGatewayService, + ProvideAntigravityGatewayService, ProvideRateLimitService, ProvideAccountUsageService, ProvideAccountTestService, diff --git a/backend/migrations/255_prompt_rule_policy_v2.sql b/backend/migrations/255_prompt_rule_policy_v2.sql new file mode 100644 index 000000000000..356fe7e590c4 --- /dev/null +++ b/backend/migrations/255_prompt_rule_policy_v2.sql @@ -0,0 +1,230 @@ +-- Freeze legacy global references and preserve each domain's effective switch. +-- The archived policy/runtime and original Claude settings remain available for +-- inspection; application traffic reads only the v2 policy after this migration. +ALTER TABLE system_prompt_rule_policies + ADD COLUMN IF NOT EXISTS legacy_policy JSONB, + ADD COLUMN IF NOT EXISTS legacy_runtime JSONB; + +ALTER TABLE system_prompt_template_versions + DROP CONSTRAINT IF EXISTS system_prompt_template_versions_composition; +ALTER TABLE system_prompt_template_versions + ADD CONSTRAINT system_prompt_template_versions_composition CHECK ( + (composition_mode IN ('inline', 'anthropic_system_blocks') + AND bundle_id IS NULL AND bundle_manifest_sha256 IS NULL) + OR (composition_mode = 'codex_skill_hybrid' + AND bundle_id = 'codexrip-reverse-skill' AND bundle_manifest_sha256 IS NULL) + ); + +DO $prompt_v2$ +DECLARE + old_policy JSONB; + old_runtime JSONB; + old_rule JSONB; + new_rule JSONB; + new_rules JSONB := '[]'::jsonb; + new_defaults JSONB := '[]'::jsonb; + omitted_ids JSONB := '[]'::jsonb; + reference_id JSONB; + selected_template BIGINT; + selected_version BIGINT; + resolved_role TEXT; + prompt_enabled BOOLEAN; + configured_blocks TEXT; + expansion_prompt TEXT; + raw_blocks JSONB; + normalized_blocks JSONB := '[]'::jsonb; + old_block JSONB; + new_block JSONB; + cache_control JSONB; + block_type TEXT; + migrated_body TEXT; + migrated_template BIGINT; + migrated_version BIGINT; + claude_rule_id TEXT := 'migrated-claude-oauth'; + suffix INTEGER := 0; + -- Match strings.TrimSpace used by the legacy Claude parser, including + -- Unicode whitespace, so an empty setting never becomes custom content. + trim_chars TEXT := CHR(9) || CHR(10) || CHR(11) || CHR(12) || CHR(13) || CHR(32) + || CHR(133) || CHR(160) || CHR(5760) || CHR(8192) || CHR(8193) || CHR(8194) + || CHR(8195) || CHR(8196) || CHR(8197) || CHR(8198) || CHR(8199) || CHR(8200) + || CHR(8201) || CHR(8202) || CHR(8232) || CHR(8233) || CHR(8239) || CHR(8287) || CHR(12288); +BEGIN + SELECT p.policy, to_jsonb(r) INTO old_policy, old_runtime + FROM system_prompt_rule_policies p JOIN system_prompt_runtime r ON r.id = p.id + WHERE p.id = 1 FOR UPDATE OF p, r; + IF old_policy IS NULL OR old_policy->>'version' = '2' THEN + RETURN; + END IF; + IF old_policy->>'version' IS DISTINCT FROM '1' THEN + RAISE EXCEPTION 'unsupported legacy prompt policy version'; + END IF; + + FOR old_rule IN SELECT value FROM jsonb_array_elements(old_policy->'rules') LOOP + selected_template := (old_rule->>'template_id')::bigint; + selected_version := (old_rule->>'version_id')::bigint; + IF COALESCE((old_rule->>'follow_active')::boolean, false) THEN + selected_template := (old_runtime->>'active_template_id')::bigint; + selected_version := (old_runtime->>'active_version_id')::bigint; + END IF; + IF COALESCE(selected_template, 0) < 1 OR COALESCE(selected_version, 0) < 1 THEN + -- Migration 253 installs an empty seed placeholder on a fresh + -- database. It has no content or effective behavior to preserve. + IF old_rule->>'id' = 'legacy-default' + AND NOT (old_runtime->>'enabled')::boolean + AND NOT EXISTS ( + SELECT 1 FROM accounts a + WHERE a.deleted_at IS NULL + AND a.extra->'prompt_skills'->>'mode' = 'custom' + AND a.extra->'prompt_skills'->'rule_ids' ? (old_rule->>'id') + ) THEN + omitted_ids := omitted_ids || jsonb_build_array(old_rule->>'id'); + CONTINUE; + END IF; + RAISE EXCEPTION 'legacy prompt rule has no immutable content'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM system_prompt_template_versions v + JOIN system_prompt_templates t ON t.id = v.template_id + WHERE v.id = selected_version AND t.id = selected_template AND t.deleted_at IS NULL + ) THEN + RAISE EXCEPTION 'legacy prompt rule content reference is invalid'; + END IF; + resolved_role := CASE old_rule->>'delivery' + WHEN 'native_control' THEN 'auto' + WHEN 'system' THEN 'system' + WHEN 'developer' THEN 'developer' + ELSE NULL END; + IF resolved_role IS NULL THEN + RAISE EXCEPTION 'legacy prompt delivery is invalid'; + END IF; + new_rule := (old_rule - 'follow_active' - 'delivery') || jsonb_build_object( + 'template_id', selected_template, 'version_id', selected_version, + 'role', resolved_role, 'platforms', jsonb_build_array('openai', 'cindy'), + 'enabled', COALESCE((old_rule->>'enabled')::boolean, false) + AND (old_runtime->>'enabled')::boolean + ); + new_rules := new_rules || jsonb_build_array(new_rule); + END LOOP; + FOR reference_id IN SELECT value FROM jsonb_array_elements(old_policy->'default_rule_ids') LOOP + IF NOT omitted_ids ? (reference_id #>> '{}') THEN + new_defaults := new_defaults || jsonb_build_array(reference_id); + END IF; + END LOOP; + + -- Only explicit custom content creates a Claude rule. Empty settings keep + -- the protocol adapter's built-in billing and Claude Code identity blocks. + SELECT COALESCE(NULLIF(value, ''), 'true') = 'true' INTO prompt_enabled + FROM settings WHERE key = 'enable_claude_oauth_system_prompt_injection'; + prompt_enabled := COALESCE(prompt_enabled, true); + SELECT value INTO configured_blocks FROM settings WHERE key = 'claude_oauth_system_prompt_blocks'; + SELECT value INTO expansion_prompt FROM settings WHERE key = 'claude_oauth_system_prompt'; + configured_blocks := BTRIM(COALESCE(configured_blocks, ''), trim_chars); + expansion_prompt := BTRIM(COALESCE(expansion_prompt, ''), trim_chars); + raw_blocks := '[]'::jsonb; + IF configured_blocks <> '' THEN + raw_blocks := configured_blocks::jsonb; + IF jsonb_typeof(raw_blocks) = 'object' THEN + raw_blocks := COALESCE(NULLIF(raw_blocks->'blocks', 'null'::jsonb), '[]'::jsonb); + END IF; + IF raw_blocks = 'null'::jsonb THEN + raw_blocks := '[]'::jsonb; + END IF; + IF jsonb_typeof(raw_blocks) IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'legacy Claude prompt blocks are invalid'; + END IF; + END IF; + IF jsonb_array_length(raw_blocks) = 0 AND expansion_prompt <> '' THEN + raw_blocks := '[ + {"type":"text","text":"{billing_header}"}, + {"type":"text","text":"{claude_code_system_prompt}"}, + {"type":"text","text":"{claude_code_expansion_prompt}","cache_control":{"type":"ephemeral","ttl":"5m"}} + ]'::jsonb; + END IF; + IF jsonb_array_length(raw_blocks) > 0 THEN + FOR old_block IN SELECT value FROM jsonb_array_elements(raw_blocks) LOOP + IF old_block = 'null'::jsonb THEN + old_block := '{}'::jsonb; + END IF; + IF jsonb_typeof(old_block) IS DISTINCT FROM 'object' + OR COALESCE(jsonb_typeof(old_block->'enabled'), 'null') NOT IN ('boolean', 'null') + OR COALESCE(jsonb_typeof(old_block->'type'), 'null') NOT IN ('string', 'null') + OR COALESCE(jsonb_typeof(old_block->'text'), 'null') NOT IN ('string', 'null') THEN + RAISE EXCEPTION 'legacy Claude prompt block shape is invalid'; + END IF; + block_type := COALESCE(NULLIF(BTRIM(old_block->>'type', trim_chars), ''), 'text'); + IF block_type <> 'text' THEN + RAISE EXCEPTION 'legacy Claude prompt block type is invalid'; + END IF; + new_block := jsonb_build_object( + 'type', 'text', 'text', COALESCE(old_block->>'text', ''), + 'enabled', COALESCE((old_block->>'enabled')::boolean, true) + ); + cache_control := old_block->'cache_control'; + IF cache_control = 'true'::jsonb THEN + cache_control := '{"type":"ephemeral","ttl":"5m"}'::jsonb; + ELSIF cache_control IS NULL OR cache_control IN ('false'::jsonb, 'null'::jsonb) THEN + cache_control := NULL; + ELSIF jsonb_typeof(cache_control) <> 'object' THEN + RAISE EXCEPTION 'legacy Claude prompt cache control is invalid'; + END IF; + IF cache_control IS NOT NULL THEN + new_block := new_block || jsonb_build_object('cache_control', cache_control); + END IF; + normalized_blocks := normalized_blocks || jsonb_build_array(new_block); + END LOOP; + -- Preserve single-pass placeholder expansion: a configured expansion + -- containing a placeholder is literal replacement text, not a template. + migrated_body := jsonb_build_object('blocks', normalized_blocks, + 'expansion_prompt', expansion_prompt)::text; + IF OCTET_LENGTH(migrated_body) > 65536 THEN + RAISE EXCEPTION 'migrated Claude prompt exceeds the immutable content limit'; + END IF; + WHILE EXISTS (SELECT 1 FROM jsonb_array_elements(new_rules) item WHERE item->>'id' = claude_rule_id) + OR EXISTS (SELECT 1 FROM system_prompt_templates WHERE slug = claude_rule_id) LOOP + suffix := suffix + 1; + claude_rule_id := 'migrated-claude-oauth-' || suffix; + END LOOP; + INSERT INTO system_prompt_templates (slug, name, description) + VALUES (claude_rule_id, 'Claude OAuth custom instructions', 'Migrated explicit Claude OAuth system blocks') + RETURNING id INTO migrated_template; + INSERT INTO system_prompt_template_versions + (template_id, version, body, sha256, byte_length, composition_mode, note, published_at) + VALUES (migrated_template, 1, migrated_body, + encode(sha256(convert_to(migrated_body, 'UTF8')), 'hex'), OCTET_LENGTH(migrated_body), + 'anthropic_system_blocks', 'Migrated from Claude OAuth custom prompt settings', NOW()) + RETURNING id INTO migrated_version; + new_rules := new_rules || jsonb_build_array(jsonb_build_object( + 'id', claude_rule_id, 'name', 'Claude OAuth custom instructions', 'enabled', prompt_enabled, + 'template_id', migrated_template, 'version_id', migrated_version, + 'role', 'auto', 'position', 'control_append', 'order', 100, + 'platforms', jsonb_build_array('anthropic'), 'account_types', jsonb_build_array('oauth', 'setup-token'), + 'request_profiles', jsonb_build_array('generic-mimic'), + 'exclude_model_contains', jsonb_build_array('fable'), + 'model_match', 'upstream', 'models', '[]'::jsonb + )); + new_defaults := new_defaults || jsonb_build_array(claude_rule_id); + END IF; + IF jsonb_array_length(new_rules) > 64 THEN + RAISE EXCEPTION 'migrated prompt policy exceeds the rule limit'; + END IF; + UPDATE system_prompt_rule_policies + SET legacy_policy = COALESCE(legacy_policy, old_policy), + legacy_runtime = COALESCE(legacy_runtime, old_runtime), + policy = jsonb_build_object('version', 2, 'rules', new_rules, 'default_rule_ids', new_defaults) + WHERE id = 1; + UPDATE system_prompt_runtime + SET enabled = true, revision = revision + 1, updated_at = NOW() + WHERE id = 1; +END; +$prompt_v2$; + +DO $prompt_v2_constraint$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'system_prompt_rule_policy_v2' + AND conrelid = 'system_prompt_rule_policies'::regclass) THEN + ALTER TABLE system_prompt_rule_policies + ADD CONSTRAINT system_prompt_rule_policy_v2 CHECK (policy->>'version' = '2'); + END IF; +END; +$prompt_v2_constraint$; diff --git a/frontend/src/api/admin/__tests__/systemPrompts.spec.ts b/frontend/src/api/admin/__tests__/systemPrompts.spec.ts index dd1b2a47df25..e38ee5dfd327 100644 --- a/frontend/src/api/admin/__tests__/systemPrompts.spec.ts +++ b/frontend/src/api/admin/__tests__/systemPrompts.spec.ts @@ -4,10 +4,28 @@ const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: 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() @@ -65,10 +83,10 @@ describe('System Prompts API', () => { client.post.mockResolvedValue({ data: {} }) client.put.mockResolvedValue({ data: {} }) - await systemPromptsAPI.publish(12, 33, 7) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/versions/33/publish', { expected_revision: 7 }) - await systemPromptsAPI.publish(12, 22, 8, true) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/12/versions/22/rollback', { expected_revision: 8 }) + 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 })) @@ -98,9 +116,9 @@ describe('System Prompts API', () => { 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) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/12/publish', { expected_revision: 3 }) - await systemPromptsAPI.publishSkillVersion(9, 4, true) - expect(client.post).toHaveBeenCalledWith('/admin/system-prompts/skill-registry/versions/9/rollback', { expected_revision: 4 }) + 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 }) }) }) diff --git a/frontend/src/api/admin/systemPromptRules.ts b/frontend/src/api/admin/systemPromptRules.ts index e0b2b4425294..e068f6a6ce76 100644 --- a/frontend/src/api/admin/systemPromptRules.ts +++ b/frontend/src/api/admin/systemPromptRules.ts @@ -1,22 +1,56 @@ import { apiClient } from '../client' -export type PromptDelivery = 'native_control' | 'system' | 'developer' -export type PromptPosition = 'control_prepend' | 'control_append' | 'conversation_head' | 'conversation_tail' +import type { SystemPromptCompositionMode } from './systemPrompts' + +export type PromptRole = 'auto' | 'system' | 'developer' +export type PromptPosition = 'control_prepend' | 'control_append' | 'conversation_head' | 'conversation_tail' | 'before_last_user' | 'after_last_user' export interface PromptRule { id: string name: string enabled: boolean template_id: number version_id: number - follow_active?: boolean order: number - delivery: PromptDelivery + role: PromptRole position: PromptPosition + platforms: string[] + account_types?: string[] + exclude_model_contains?: string[] + request_profiles?: string[] model_match: 'requested' | 'upstream' models: string[] } export interface PromptRulePolicy { version: number; rules: PromptRule[]; default_rule_ids: string[] } export interface PromptRuleState { policy: PromptRulePolicy; revision: number; enabled: boolean; compact_enabled: boolean } +export interface PromptContent { + body: string + available?: boolean + composition_mode: SystemPromptCompositionMode + managed: boolean + template_id: number + version_id: number +} +export interface PromptCapability { + protocol: string + platforms: string[] + roles: PromptRole[] + positions_by_role: Partial> + limitations: string[] + conversation_system_models?: string[] +} +export interface PromptConfig extends PromptRuleState { + expose_server_prompt: boolean + contents: Record + capabilities: PromptCapability[] +} +export interface PromptConfigWrite { + expected_revision: number + enabled: boolean + expose_server_prompt: boolean + compact_enabled: boolean + policy: PromptRulePolicy + contents: Record +} export interface PromptBinding { mode: 'inherit' | 'off' | 'custom'; rule_ids: string[] } export interface PromptAccountBinding { account_id: number; name: string; platform: string; account_type: string; updated_at: string @@ -30,15 +64,39 @@ export interface PromptPreview { application: { applied: boolean; revision: number; rules_plan?: { sha256: string - placements: Array<{ rule_id: string; version_id: number; delivery: string; position: string; carrier: string; role?: string; body: string; 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 }> } } } -export const legalPromptPosition = (delivery: PromptDelivery, position: PromptPosition) => delivery !== 'native_control' || position.startsWith('control_') +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 + // Antigravity upstream accounts forward Messages; OAuth uses Gemini. Match + // the server's save-time scope selection and leave broad scopes to preview. + const usesMessages = (accountTypes.length === 1 && accountTypes[0] === 'upstream') || (!accountTypes.length && !position.startsWith('control_')) + return protocols.filter(capability => capability.protocol === (usesMessages ? 'messages' : 'gemini')) +} +export function legalPromptPosition(role: PromptRole, position: PromptPosition, platforms: string[], capabilities: PromptCapability[], accountTypes: string[] = []): boolean { + if (!platforms.length) return false + return platforms.every(platform => { + const protocols = scopedPromptCapabilities(platform, position, capabilities, accountTypes) + return protocols.some(capability => capability.positions_by_role[role]?.includes(position)) + }) +} +export function legalPromptModelScope(rule: PromptRule, capabilities: PromptCapability[]): boolean { + if (rule.position.startsWith('control_')) return true + return rule.platforms.every(platform => scopedPromptCapabilities(platform, rule.position, capabilities, rule.account_types || []).some(capability => { + if (!capability.positions_by_role[rule.role]?.includes(rule.position)) return false + if (!capability.conversation_system_models?.length) return true + return rule.model_match === 'upstream' && rule.models.length > 0 && rule.models.every(model => capability.conversation_system_models!.includes(model)) + })) +} 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, read: async () => (await apiClient.get(`${basePath}/rules`)).data, save: async (policy: PromptRulePolicy, revision: number) => (await apiClient.put(`${basePath}/rules`, { policy, expected_revision: revision })).data, @@ -46,6 +104,6 @@ export const rulesAPI = { (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; binding?: PromptBinding; simulate_enabled?: boolean }) => + 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 38bde0435b35..b2036f2b358b 100644 --- a/frontend/src/api/admin/systemPrompts.ts +++ b/frontend/src/api/admin/systemPrompts.ts @@ -1,6 +1,7 @@ import { apiClient } from '../client' +import type { PromptConfig } from './systemPromptRules' -export type SystemPromptCompositionMode = 'inline' | 'codex_skill_hybrid' +export type SystemPromptCompositionMode = 'inline' | 'codex_skill_hybrid' | 'anthropic_system_blocks' export type SystemPromptClientMode = 'codex' | 'openai_compatible' export type RemoteSkillSourceID = 'moxinggang' @@ -270,12 +271,13 @@ export async function getSkillSync(id: number): Promise { export async function publishSkillVersion( id: number, expectedRevision: number, - rollback = false + 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 } + { expected_revision: expectedRevision, ...targets } ) return data } @@ -324,12 +326,13 @@ export async function publish( id: number, versionId: number, expectedRevision: number, - rollback = false -): Promise { + rollback: boolean, + targetRuleIDs: string[], +): Promise { const action = rollback ? 'rollback' : 'publish' - const { data } = await apiClient.post( + const { data } = await apiClient.post( `/admin/system-prompts/${id}/versions/${versionId}/${action}`, - { expected_revision: expectedRevision } + { expected_revision: expectedRevision, target_rule_ids: targetRuleIDs } ) return data } diff --git a/frontend/src/components/admin/systemPrompt/PromptRulesManager.vue b/frontend/src/components/admin/systemPrompt/PromptRulesManager.vue index 031a5f02c5b8..aa661f67d8ac 100644 --- a/frontend/src/components/admin/systemPrompt/PromptRulesManager.vue +++ b/frontend/src/components/admin/systemPrompt/PromptRulesManager.vue @@ -1,154 +1,159 @@ - - - -
-
- - - - - - - - - - - -
{{ t('admin.systemPrompts.history.version') }}{{ t('admin.systemPrompts.history.status') }}{{ t('admin.systemPrompts.history.created') }}{{ t('admin.systemPrompts.history.actions') }}
{{ version.id === runtimeVersionId ? t('admin.systemPrompts.history.active') : t('admin.systemPrompts.history.candidate') }}{{ formatDate(version.created_at) }} - - -
{{ t('admin.systemPrompts.history.empty') }}
-
-
- - -
{{ t('admin.systemPrompts.templates.select') }}
- - + +
+

{{ text('conflict') }}

{{ text('conflictMergeHint') }}

+
{{ text('serverChanges') }} · {{ text('revision') }} {{ pendingRemote.revision }}
{{ rule.name }}

{{ text(rule.role) }} · {{ text(rule.position) }} · {{ rule.platforms.join(', ') }} · {{ rule.enabled ? text('enabled') : text('rule_disabled') }} · {{ text('default') }}

{{ rule.models.join(', ') }}

{{ pendingRemote.contents[rule.id]?.body }}
+ +
+

{{ text('invalid') }}

+

{{ t('admin.systemPrompts.common.loading') }}

+ - - - -
-
-
-
-
-
- - -
-
-
-
-
-
-
-
- - -
-
{{ t('admin.systemPrompts.source.repository') }}: {{ versionDetails.source_repository }}
{{ t('admin.systemPrompts.source.version') }}: {{ versionDetails.source_version }}
{{ t('admin.systemPrompts.source.commit') }}: {{ versionDetails.source_commit }}
{{ t('admin.systemPrompts.source.artifact') }}: {{ versionDetails.source_artifact }}
{{ versionDetails.source_artifact_sha256 }}
{{ versionDetails.source_license_sha256 }}
-
{{ t('admin.systemPrompts.source.inline') }}
-
-
- - -
-
-
-
-
-
- - + :open="advancedOpen" :runtime="baseline ? { revision: baseline.revision } : null" + :runtime-draft="draft || { enabled: false, expose_server_prompt: false, compact_enabled: false }" + :runtime-dirty="dirty && !invalid && !pendingRemote && !loading" :saving-runtime="saving" :skill-registry="skillRegistry" :skill-loading="skillLoading" + :skill-sync-job="skillSyncJob" :skill-candidate="skillCandidate" :skill-syncing="skillSyncing" :publishing-skill="publishingSkill" + :skill-target-rule-names="skillTargets.map(rule => rule.name)" :skill-publish-blocked="dirty || !!pendingRemote" + :source-template="detail?.template || null" :source-template-display-name="selectedRule?.name || ''" :source-version="selectedVersion" + :source-sync-status="sourceSyncStatus" :source-candidate="sourceCandidate" :source-syncing="sourceSyncing" + @close="advancedOpen = false" @runtime-change="updateRuntimeDraft" @save-runtime="save" @sync-skill="startSkillSync" @publish-skill="publishSkillBundle" @sync-source="syncManagedSource" + > + +
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 53b98a5c3a0c..a2fb7c5869e6 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -1475,17 +1475,26 @@ describe("admin SettingsView payment visible method controls", () => { ); }); - it("submits Claude OAuth system prompt injection gateway settings", async () => { + it("keeps Claude OAuth protocol controls while routing custom edits to unified prompts", async () => { const blocks = `[{"type":"text","text":"custom block","cache_control":true}]`; + const legacyPrompt = " Previous custom prompt\nKeep original bytes "; getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, - enable_claude_oauth_system_prompt_injection: false, + enable_claude_oauth_system_prompt_injection: true, + claude_oauth_system_prompt: legacyPrompt, claude_oauth_system_prompt_blocks: blocks, }); const wrapper = mountView(); await flushPromises(); + await openGatewayTab(wrapper); + expect(wrapper.get('[data-test="claude-unified-prompts-link"]').attributes("href")).toBe("/admin/system-prompts"); + const archive = wrapper.get('[data-test="claude-legacy-prompt-reference"]'); + expect(archive.find("input, textarea, button").exists()).toBe(false); + expect(archive.get('[data-test="claude-legacy-prompt-text"]').element.textContent).toBe(legacyPrompt); + expect(archive.get('[data-test="claude-legacy-prompt-blocks"]').element.textContent).toBe(blocks); + await wrapper.get('[data-test="claude-protocol-base-instructions"]').setValue(false); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); @@ -1495,20 +1504,14 @@ describe("admin SettingsView payment visible method controls", () => { enable_claude_oauth_system_prompt_injection: false, }), ); - const payload = updateSettings.mock.calls[0][0] as { - claude_oauth_system_prompt_blocks: string; - }; - expect(JSON.parse(payload.claude_oauth_system_prompt_blocks)).toEqual([ - { - enabled: true, - type: "text", - text: "custom block", - cache_control: { - type: "ephemeral", - ttl: "5m", - }, - }, - ]); + const payload = updateSettings.mock.calls[0][0]; + expect(payload).not.toHaveProperty("claude_oauth_system_prompt"); + expect(payload).not.toHaveProperty("claude_oauth_system_prompt_blocks"); + expect(enSettings.settings.gatewayForwarding.claudeOAuthSystemPromptInjection).toContain("protocol base instructions"); + expect(zhSettings.settings.gatewayForwarding.claudeOAuthSystemPromptInjection).toContain("协议基础指令"); + expect(enSettings.settings.gatewayForwarding.customPromptManagementLink).toBe("Open System Prompts"); + expect(zhSettings.settings.gatewayForwarding.customPromptManagementLink).toBe("打开系统提示词"); + wrapper.unmount(); }); it("submits Antigravity user agent version gateway setting", async () => { diff --git a/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts b/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts index 095ca6a60cc8..4da8e10d1414 100644 --- a/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts +++ b/frontend/src/views/admin/__tests__/SystemPromptsView.locale.spec.ts @@ -3,16 +3,13 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createI18n } from 'vue-i18n' import en from '@/i18n/locales/en/admin/systemPrompts' import zh from '@/i18n/locales/zh/admin/systemPrompts' +import { promptConfig } from './systemPromptFixtures' -const api = vi.hoisted(() => ({ - list: vi.fn(), get: vi.fn(), create: vi.fn(), saveDraft: vi.fn(), updateMetadata: vi.fn(), - updateRuntime: vi.fn(), publish: vi.fn(), remove: vi.fn(), duplicate: vi.fn(), - getSkillRegistry: vi.fn(), startSkillSync: vi.fn(), publishSkillVersion: vi.fn(), -})) -const notifications = vi.hoisted(() => ({ showError: vi.fn(), showSuccess: vi.fn(), showWarning: vi.fn() })) -vi.mock('@/api/admin/systemPrompts', () => ({ default: api })) +const api = vi.hoisted(() => ({ config: vi.fn(), saveConfig: vi.fn(), preview: vi.fn() })) +const notifications = vi.hoisted(() => ({ showSuccess: vi.fn() })) +vi.mock('@/api/admin/systemPromptRules', async () => ({ ...await vi.importActual('@/api/admin/systemPromptRules'), rulesAPI: api })) vi.mock('@/stores/app', () => ({ useAppStore: () => notifications })) - +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 1 } }) })) import App from '../SystemPromptsView.vue' function messages(value: object, prefix = ''): Record { @@ -22,70 +19,39 @@ function messages(value: object, prefix = ''): Record { })) } const wrappers: VueWrapper[] = [] - -describe('prompt-skills site language', () => { - beforeEach(() => { - Object.values(api).forEach(mock => mock.mockReset()) - Object.values(notifications).forEach(mock => mock.mockReset()) - vi.stubGlobal('fetch', vi.fn(() => { throw new Error('Unexpected network in locale regression') })) - const template = { id: 5, slug: 'user-template', name: 'Keep 用户名称', description: '', is_seed: false, managed_source: '', created_at: '', updated_at: '' } - const runtime = { enabled: true, expose_server_prompt: false, compact_enabled: false, revision: 7, template_id: 5, version_id: 50, degraded: false } - const version = { id: 50, template_id: 5, version: 2, body: 'Original 原文', note: 'Original note', composition_mode: 'inline', is_active: true, created_at: '' } - api.list.mockResolvedValue({ templates: [template], runtime }) - api.get.mockResolvedValue({ template, runtime, versions: [version] }) - }) - afterEach(() => { - wrappers.splice(0).forEach(wrapper => wrapper.unmount()) - expect(fetch).not.toHaveBeenCalled() - vi.unstubAllGlobals() - }) - - it('owns complete distinct English messages instead of aliasing Chinese', () => { - const english = messages(en) - const chinese = messages(zh) +beforeEach(() => { + sessionStorage.clear() + Object.values(api).forEach(mock => mock.mockReset()) + const config = promptConfig(); config.policy.rules[0]!.name = 'Keep 用户名称' + api.config.mockResolvedValue(config) +}) +afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) +describe('unified prompt editor site language', () => { + it('owns complete English and Chinese messages', () => { + const english = messages(en), chinese = messages(zh) expect(Object.keys(english).sort()).toEqual(Object.keys(chinese).sort()) - for (const [key, value] of Object.entries(english)) { - expect(value.trim(), key).not.toBe('') - expect(value, key).not.toMatch(/\p{Script=Han}/u) - } + for (const [key, value] of Object.entries(english)) { expect(value.trim(), key).not.toBe(''); expect(value, key).not.toMatch(/\p{Script=Han}/u) } expect(en.systemPrompts.title).toBe('System Prompts') expect(zh.systemPrompts.title).toBe('系统提示词') - expect(en.systemPrompts.confirm.skillPublishMessage).toContain('paired prompt together') - expect(en.systemPrompts.confirm.deleteMessage).toContain('soft-deleted') }) - - it('switches rendered language without resetting drafts or publishing data', async () => { - const i18n = createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages: { - en: { admin: en, common: { confirm: 'Confirm', cancel: 'Cancel', close: 'Close' } }, - zh: { admin: zh, common: { confirm: '确认', cancel: '取消', close: '关闭' } }, - } }) - const wrapper = mount(App, { global: { plugins: [i18n], stubs: { - AppLayout: { template: '
' }, - Icon: true, Toggle: true, SystemPromptAdvancedDrawer: true, - BaseDialog: true, ConfirmDialog: true, - } } }) - wrappers.push(wrapper) + it('switches language without resetting content or settings drafts', async () => { + const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: { admin: en }, zh: { admin: zh } } }) + const wrapper = mount(App, { global: { plugins: [i18n], stubs: { AppLayout: { template: '
' }, SystemPromptAdvancedDrawer: true } } }); wrappers.push(wrapper) await flushPromises() - expect(wrapper.get('h1').text()).toBe('System Prompts') - expect(wrapper.text()).toContain('Keep 用户名称') await wrapper.get('[data-test="system-prompt-body"]').setValue('Unsaved 原文\nKeep bytes') + await wrapper.get('[data-test="prompt-position"]').setValue('after_last_user') const editor = wrapper.get('[data-test="system-prompt-body"]').element - i18n.global.locale.value = 'zh' - await flushPromises() + i18n.global.locale.value = 'zh'; await flushPromises() expect(wrapper.get('h1').text()).toBe('系统提示词') - expect(wrapper.get('[data-test="system-prompt-save-version"]').text()).toBe('保存为新版本') + expect(wrapper.get('[data-test="save-rules"]').text()).toBe('保存并生效') + expect(wrapper.text()).toContain('Keep 用户名称') expect(wrapper.get('[data-test="system-prompt-body"]').element).toBe(editor) expect(editor).toHaveProperty('value', 'Unsaved 原文\nKeep bytes') - i18n.global.locale.value = 'en' - await flushPromises() - expect(wrapper.get('h1').text()).toBe('System Prompts') - expect(wrapper.get('[data-test="system-prompt-save-version"]').text()).toBe('Save as a new version') - expect(editor).toHaveProperty('value', 'Unsaved 原文\nKeep bytes') - expect(api.list).toHaveBeenCalledTimes(1) - expect(api.get).toHaveBeenCalledTimes(1) - for (const [name, mock] of Object.entries(api)) { - if (name !== 'list' && name !== 'get') expect(mock, name).not.toHaveBeenCalled() - } - expect(notifications.showError).not.toHaveBeenCalled() + i18n.global.locale.value = 'en'; await flushPromises() + expect(wrapper.get('[data-test="save-rules"]').text()).toBe('Save and apply') + expect(wrapper.get('[data-test="prompt-position"]').element).toHaveProperty('value', 'after_last_user') + expect(api.config).toHaveBeenCalledTimes(1) + expect(api.saveConfig).not.toHaveBeenCalled() + expect(api.preview).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts b/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts index 06e6050d9d33..1baaa54b556b 100644 --- a/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts @@ -1,484 +1,225 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { defineComponent } from 'vue' -import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import en from '@/i18n/locales/en/admin/systemPrompts' +import zh from '@/i18n/locales/zh/admin/systemPrompts' +import { promptConfig, template, version } from './systemPromptFixtures' + +const api = vi.hoisted(() => ({ config: vi.fn(), saveConfig: vi.fn(), preview: vi.fn(), list: vi.fn(), get: vi.fn(), getSkillRegistry: vi.fn(), getSkillVersion: vi.fn(), startSkillSync: vi.fn(), getSkillSync: vi.fn(), publishSkillVersion: vi.fn(), syncManagedSource: vi.fn() })) +const notify = vi.hoisted(() => ({ showSuccess: vi.fn() })) +vi.mock('@/api/admin/systemPromptRules', async () => ({ ...await vi.importActual('@/api/admin/systemPromptRules'), rulesAPI: api })) +vi.mock('@/api/admin/systemPrompts', () => ({ default: api })) +vi.mock('@/stores/app', () => ({ useAppStore: () => notify })) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 1 } }) })) import SystemPromptsView from '../SystemPromptsView.vue' -import SystemPromptAdvancedDrawer from '@/components/admin/systemPrompt/SystemPromptAdvancedDrawer.vue' - -const mocks = vi.hoisted(() => ({ - list: vi.fn(), get: vi.fn(), create: vi.fn(), updateMetadata: vi.fn(), saveDraft: vi.fn(), - syncManagedSource: vi.fn(), publish: vi.fn(), updateRuntime: vi.fn(), duplicate: vi.fn(), remove: vi.fn(), - getSkillRegistry: vi.fn(), startSkillSync: vi.fn(), getSkillSync: vi.fn(), getSkillVersion: vi.fn(), publishSkillVersion: vi.fn(), - showSuccess: vi.fn(), showError: vi.fn(), showWarning: vi.fn(), -})) - -vi.mock('@/api/admin/systemPrompts', () => ({ default: mocks })) -vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showSuccess: mocks.showSuccess, showError: mocks.showError, showWarning: mocks.showWarning, -}) })) -vi.mock('vue-i18n', async () => { - const actual = await vi.importActual('vue-i18n') - const { default: messages } = await import('@/i18n/locales/en/admin/systemPrompts') - const en = { admin: messages, common: { confirm: 'Confirm', cancel: 'Cancel' } } - const resolveMessage = (key: string) => key.split('.').reduce((value, segment) => { - if (!value || typeof value !== 'object') return undefined - return (value as Record)[segment] - }, en) - return { ...actual, useI18n: () => ({ locale: { value: 'en' }, t: (key: string) => resolveMessage(key) || key }) } -}) - -const runtime = () => ({ - enabled: true, expose_server_prompt: false, compact_enabled: false, - template_id: 1, version_id: 10, template_version: 1, revision: 5, - sha256: 'a'.repeat(64), byte_length: 4, degraded: false, - composition_mode: 'codex_skill_hybrid', bundle_id: 'codexrip-reverse-skill', - bundle_manifest_sha256: '', registry_revision: 3, registry_raw_tree_sha256: '1'.repeat(64), - registry_effective_tree_sha256: '2'.repeat(64), registry_prompt_raw_sha256: '3'.repeat(64), - registry_prompt_effective_sha256: '4'.repeat(64), registry_upstream_source_id: 'moxinggang', - registry_upstream_root: 'https://moxinggang.com/skills/security-research/current', - registry_public_root: 'https://codexrip.vip/skills/security-research/current', - bundle_available: true, bundle_degraded: false, updated_at: '2026-08-06T00:00:00Z', -}) -const codexTemplate = () => ({ - id: 1, slug: 'codexrip_reverse_skill', name: 'CodexRip', description: 'hidden description', - is_seed: true, managed_source: 'remote_skill_registry', created_at: '2026-08-06T00:00:00Z', updated_at: '2026-08-06T00:00:00Z', -}) -const gptTemplate = () => ({ - id: 2, slug: 'gpt_5_6_instruct', name: 'GPT-5.6 Instruct', description: 'hidden source description', - is_seed: true, managed_source: 'mdx_tom_gpt_5_6_instruct', created_at: '2026-08-06T00:00:00Z', updated_at: '2026-08-06T00:00:00Z', -}) -const version = (templateId = 1) => ({ - id: templateId === 1 ? 10 : 20, template_id: templateId, version: 1, body: 'seed', sha256: 'a'.repeat(64), - byte_length: 4, note: 'seed', composition_mode: templateId === 1 ? 'codex_skill_hybrid' : 'inline', - bundle_id: templateId === 1 ? 'codexrip-reverse-skill' : '', bundle_manifest_sha256: '', - source_repository: templateId === 2 ? 'MDX-Tom/gpt-5.6-instruct' : '', source_commit: templateId === 2 ? '7'.repeat(40) : '', - source_version: templateId === 2 ? 'v45' : '', source_artifact: templateId === 2 ? 'gpt-5.6-sol-unrestricted-v45.zip' : '', - source_artifact_sha256: templateId === 2 ? 'c'.repeat(64) : '', source_license_sha256: templateId === 2 ? 'd'.repeat(64) : '', - created_at: '2026-08-06T00:00:00Z', is_active: templateId === 1, -}) -const skillRegistry = () => { - const activePrompt = { id: 40, raw_sha256: '3'.repeat(64), effective_sha256: '4'.repeat(64), diff: 'routing diff', created_at: '2026-08-11T00:00:00Z' } - const active = { id: 30, upstream_source_id: 'moxinggang', upstream_root: 'https://moxinggang.com/skills/security-research/current', public_root: 'https://codexrip.vip/skills/security-research/current', raw_tree_sha256: '1'.repeat(64), effective_tree_sha256: '2'.repeat(64), prompt_version_id: 40, file_count: 458, raw_total_bytes: 3000, effective_total_bytes: 3100, added_files: 0, modified_files: 0, deleted_files: 0, script_changes: 0, binary_changes: 0, fetched_at: '2026-08-11T00:00:00Z', created_at: '2026-08-11T00:00:00Z' } - const previous = { ...active, id: 29, effective_tree_sha256: '9'.repeat(64) } - return { - runtime: { revision: 3, active, active_prompt: activePrompt, degraded: false, updated_at: '2026-08-11T00:00:00Z' }, - versions: [active, previous], - source: { upstream_source_id: 'moxinggang', upstream_root: 'https://moxinggang.com/skills/security-research/current', public_root: 'https://codexrip.vip/skills/security-research/current' }, - } -} -const skillVersionDetail = () => ({ - ...skillRegistry().runtime.active!, - prompt: { - ...skillRegistry().runtime.active_prompt!, - raw_body: 'model-gang raw body', - effective_body: 'server effective body', - }, - file_changes: [], - verified: true, -}) - -const ToggleStub = defineComponent({ - props: ['modelValue'], emits: ['update:modelValue'], - template: '', -}) -const ConfirmStub = defineComponent({ - props: ['show'], emits: ['confirm', 'cancel'], - template: '
', -}) +const wrappers: VueWrapper[] = [] function mountView() { - return mount(SystemPromptsView, { - global: { - stubs: { - ExtensionSurface: { template: '
' }, - AppLayout: { template: '
' }, - Icon: true, - Toggle: ToggleStub, - BaseDialog: { template: '
', props: ['show'] }, - ConfirmDialog: ConfirmStub, - Teleport: true, - }, - }, - }) + const wrapper = mount(SystemPromptsView, { global: { plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { admin: en }, zh: { admin: zh } } })], stubs: { AppLayout: { template: '
' }, Icon: true, teleport: true, transition: false } } }) + wrappers.push(wrapper) + return wrapper } - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((done, fail) => { resolve = done; reject = fail }) - return { promise, resolve, reject } +function savedConfig() { + const next = promptConfig() + next.revision = 8 + next.policy.rules[0]!.name = 'Renamed' + next.policy.rules[0]!.role = 'developer' + next.policy.rules[0]!.position = 'after_last_user' + next.policy.rules[0]!.version_id = 11 + next.policy.default_rule_ids = [] + next.contents.first = { ...next.contents.first!, body: 'Edited text', version_id: 11 } + return next } - -const thirdTemplate = () => ({ ...gptTemplate(), id: 3, slug: 'custom', name: 'Custom', managed_source: '' }) -const templateDetail = (id: number) => ({ - template: id === 1 ? codexTemplate() : id === 2 ? gptTemplate() : thirdTemplate(), - versions: [{ ...version(id), id: id * 10, body: id === 3 ? 'third body' : 'seed' }], runtime: runtime(), +beforeEach(() => { + sessionStorage.clear() + Object.values(api).forEach(mock => mock.mockReset()) + api.config.mockResolvedValue(promptConfig()) + api.list.mockResolvedValue({ templates: [template] }) + api.get.mockResolvedValue({ template, versions: [version], runtime: {} }) + api.getSkillRegistry.mockResolvedValue({ runtime: { revision: 3 }, versions: [], source: {} }) }) +afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.restoreAllMocks() }) -describe('SystemPromptsView', () => { - beforeEach(() => { - Object.values(mocks).forEach((mock) => mock.mockReset()) - mocks.list.mockResolvedValue({ templates: [codexTemplate(), gptTemplate()], runtime: runtime() }) - mocks.get.mockImplementation(async (id: number) => ({ template: id === 2 ? gptTemplate() : codexTemplate(), versions: [version(id)], runtime: runtime() })) - mocks.getSkillRegistry.mockResolvedValue(skillRegistry()) - mocks.getSkillVersion.mockResolvedValue(skillVersionDetail()) - mocks.startSkillSync.mockResolvedValue({ id: 8, status: 'queued', progress_stage: 'queued', prompt_capture_provided: false, created_at: '2026-08-11T00:00:00Z' }) - mocks.saveDraft.mockResolvedValue({ ...version(), id: 11, version: 2, body: 'draft', note: 'seed', is_active: false }) - mocks.publish.mockResolvedValue({ ...runtime(), version_id: 11, template_version: 2, revision: 6 }) - }) - - it('shows the active effective managed body without legacy history', async () => { - const wrapper = mountView() - await flushPromises() - - expect(mocks.getSkillRegistry).toHaveBeenCalledTimes(1) - expect(mocks.getSkillVersion).toHaveBeenCalledWith(30) - expect(wrapper.find('[data-test="system-prompt-page-description"]').exists()).toBe(false) - expect(wrapper.find('[data-test="system-prompt-tab-preview"]').exists()).toBe(false) - expect(wrapper.find('[data-test="skill-registry-lifecycle"]').exists()).toBe(false) - expect(wrapper.text()).not.toContain('SHA-256') - expect(wrapper.get('[data-test="system-prompt-tab-editor"]').exists()).toBe(true) - expect(wrapper.text()).toContain('System Prompts') - expect(wrapper.text()).toContain('Security research remote Skill prompt') - expect(wrapper.text()).not.toContain('Business System Prompts') - expect(wrapper.text()).toContain('Templates') - expect(wrapper.find('[data-test="system-prompt-tab-history"]').exists()).toBe(false) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('readOnly', true) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('disabled', false) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') - expect(wrapper.get('[data-test="system-prompt-body"]').element).not.toHaveProperty('value', 'seed') - expect(wrapper.find('[data-test="system-prompt-save-version"]').exists()).toBe(false) +describe('unified prompt configuration', () => { + it('saves content, settings and defaults in one revision-checked operation', async () => { + api.saveConfig.mockResolvedValue(savedConfig()) + const wrapper = mountView(); await flushPromises() + expect(api.getSkillRegistry).not.toHaveBeenCalled() expect(wrapper.find('[data-test="system-prompt-set-current"]').exists()).toBe(false) - expect(wrapper.find('[data-test="system-prompt-edit-metadata"]').exists()).toBe(false) - expect(wrapper.find('[data-test="system-prompt-template-menu"]').exists()).toBe(false) + await wrapper.get('[data-test="prompt-name"]').setValue('Renamed') + await wrapper.get('[data-test="system-prompt-body"]').setValue('Edited text') + await wrapper.get('[data-test="prompt-role"]').setValue('developer') + await wrapper.get('[data-test="prompt-position"]').setValue('after_last_user') + await wrapper.get('[data-test="rule-default"]').setValue(false) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig).toHaveBeenCalledTimes(1) + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ expected_revision: 7, contents: { first: { body: 'Edited text' } }, policy: expect.objectContaining({ version: 2, default_rule_ids: [], rules: [expect.objectContaining({ id: 'first', name: 'Renamed', role: 'developer', position: 'after_last_user' }), expect.objectContaining({ id: 'second' })] }) })) + expect(wrapper.get('[data-test="save-rules"]').attributes('disabled')).toBeDefined() }) - - it('switches between effective and raw managed bodies', async () => { - const wrapper = mountView() - await flushPromises() - - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') - await wrapper.get('[data-test="system-prompt-managed-raw"]').trigger('click') - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'model-gang raw body') - await wrapper.get('[data-test="system-prompt-managed-effective"]').trigger('click') - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') + it('retains per-prompt drafts across selection, refresh, advanced close and page remount', async () => { + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="system-prompt-body"]').setValue('Unsent first') + await wrapper.get('[data-test="select-prompt-second"]').trigger('click') + await wrapper.get('[data-test="prompt-name"]').setValue('Unsent second name') + await wrapper.get('[data-test="system-prompt-refresh"]').trigger('click'); await flushPromises() + await wrapper.get('[data-test="open-prompt-advanced"]').trigger('click'); await flushPromises() + await wrapper.get('[data-test="system-prompt-advanced-drawer"]').trigger('keydown', { key: 'Escape' }) + await wrapper.get('[data-test="select-prompt-first"]').trigger('click') + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Unsent first') + wrapper.unmount(); wrappers.splice(wrappers.indexOf(wrapper), 1) + const next = mountView(); await flushPromises() + expect(next.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Unsent first') + await next.get('[data-test="select-prompt-second"]').trigger('click') + expect(next.get('[data-test="prompt-name"]').element).toHaveProperty('value', 'Unsent second name') + expect(api.saveConfig).not.toHaveBeenCalled() }) - - it('refreshes the active managed body', async () => { - mocks.getSkillVersion - .mockResolvedValueOnce(skillVersionDetail()) - .mockResolvedValueOnce({ - ...skillVersionDetail(), - prompt: { ...skillVersionDetail().prompt, effective_body: 'refreshed effective body' }, - }) - const wrapper = mountView() - await flushPromises() - + it('retains edits made while refresh and save responses are pending', async () => { + const wrapper = mountView(); await flushPromises() + let finishRefresh!: (value: ReturnType) => void + api.config.mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve })) await wrapper.get('[data-test="system-prompt-refresh"]').trigger('click') - await flushPromises() - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'refreshed effective body') + await wrapper.get('[data-test="system-prompt-body"]').setValue('Edited text') + finishRefresh(promptConfig()); await flushPromises() + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Edited text') + let finishSave!: (value: ReturnType) => void + api.saveConfig.mockImplementationOnce(() => new Promise(resolve => { finishSave = resolve })) + await wrapper.get('[data-test="save-rules"]').trigger('click') + await wrapper.get('[data-test="system-prompt-body"]').setValue('Typed during save') + const saved = promptConfig(); saved.revision = 8; saved.contents.first!.body = 'Edited text'; saved.contents.first!.version_id = 11; saved.policy.rules[0]!.version_id = 11 + finishSave(saved); await flushPromises() + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Typed during save') + expect(wrapper.get('[data-test="save-rules"]').attributes('disabled')).toBeUndefined() }) - - it('shows managed body unavailability without falling back to the legacy template', async () => { - mocks.getSkillVersion.mockRejectedValue(new Error('detail unavailable')) - const wrapper = mountView() - await flushPromises() - - expect(wrapper.get('[data-test="system-prompt-managed-unavailable"]').text()).toContain('The active version body is unavailable.') - expect(wrapper.find('[data-test="system-prompt-body"]').exists()).toBe(false) - expect(wrapper.text()).not.toContain('seed') + it('keeps a conflicted draft and merges untouched server changes only after explicit review', async () => { + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="prompt-name"]').setValue('Local name') + await wrapper.get('[data-test="system-prompt-body"]').setValue('Local body') + const remote = promptConfig(); remote.revision = 8; remote.policy.rules[1]!.name = 'Remote second name'; remote.policy.rules[0]!.models = ['gpt-6-astra'] + api.config.mockResolvedValue(remote) + api.saveConfig.mockRejectedValue({ response: { data: { code: 'system_prompt_revision_conflict' }, status: 409 } }) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Local body') + expect(wrapper.get('[data-test="save-rules"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="keep-prompt-draft"]').trigger('click') + expect(wrapper.text()).toContain('Remote second name') + expect(wrapper.get('[data-test="prompt-name"]').element).toHaveProperty('value', 'Local name') + expect(wrapper.get('[data-test="prompt-models"]').element).toHaveProperty('value', 'gpt-6-astra') + api.saveConfig.mockImplementation(async payload => ({ ...remote, policy: payload.policy, contents: { ...remote.contents, first: { ...remote.contents.first!, body: 'Local body' } }, revision: 9 })) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig.mock.lastCall?.[0].expected_revision).toBe(8) }) - - it('reuses the loaded registry in the advanced drawer and closes it with Escape', async () => { - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click') - await flushPromises() - - expect(mocks.getSkillRegistry).toHaveBeenCalledTimes(1) - expect(wrapper.get('[data-test="system-prompt-advanced-drawer"]').exists()).toBe(true) - await wrapper.get('[data-test="system-prompt-advanced-drawer"]').trigger('keydown', { key: 'Escape' }) - expect(wrapper.find('[data-test="system-prompt-advanced-drawer"]').exists()).toBe(false) + it('requires an explicit platform and starts with automatic control append', async () => { + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="add-prompt"]').trigger('click') + expect(wrapper.get('[data-test="prompt-role"]').element).toHaveProperty('value', 'auto') + expect(wrapper.get('[data-test="prompt-position"]').element).toHaveProperty('value', 'control_append') + await wrapper.get('[data-test="prompt-name"]').setValue('New') + await wrapper.get('[data-test="system-prompt-body"]').setValue('New body') + expect(wrapper.get('[data-test="save-rules"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="platform-gemini"]').setValue(true) + expect(wrapper.get('[data-test="save-rules"]').attributes('disabled')).toBeUndefined() + expect(wrapper.get('[data-test="prompt-role"] option[value="developer"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="prompt-position"] option[value="after_last_user"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="prompt-role"] option[value="system"]').attributes('disabled')).toBeUndefined() }) - - it('uses the fixed ModelGang source and creates a candidate without publishing', async () => { - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click') - await flushPromises() - - const drawer = wrapper.get('[data-test="system-prompt-advanced-drawer"]') - const source = drawer.get('[data-test="system-prompt-skill-source"]') - expect(source.text()).toContain('https://moxinggang.com/skills/security-research/current') - expect(source.text()).toContain('https://codexrip.vip/skills/security-research/current') - expect(drawer.text()).toContain('Fixed ModelGang source') - expect(drawer.find('select').exists()).toBe(false) - expect(drawer.text()).not.toContain('GitHub 官方') - expect(drawer.text()).not.toContain('bootstrap-reverse-skill') - expect(drawer.get('[title="Roll back"]').exists()).toBe(true) - - await drawer.get('[data-test="system-prompt-skill-sync"]').trigger('click') - await flushPromises() - expect(mocks.startSkillSync).toHaveBeenCalledWith(3, undefined) - expect(mocks.publishSkillVersion).not.toHaveBeenCalled() - }) - - it('keeps a synced source candidate inactive until its publish action is explicit', async () => { - const candidate = { - ...skillRegistry().runtime.active!, - id: 31, - raw_tree_sha256: '5'.repeat(64), - effective_tree_sha256: '6'.repeat(64), - prompt_version_id: 41, - prompt: { - id: 41, - raw_sha256: '7'.repeat(64), - effective_sha256: '8'.repeat(64), - diff: '--- raw\n+++ effective\n@@ routing\n-old\n+new', - raw_body: 'candidate raw body', - effective_body: 'candidate effective body', - created_at: '2026-08-11T00:00:00Z', - }, - file_changes: [{ path: 'SKILL.md', change: 'modified', kind: 'text' }], - modified_files: 1, - verified: true, - } - mocks.startSkillSync.mockResolvedValue({ id: 9, status: 'queued', progress_stage: 'queued', prompt_capture_provided: false, created_at: '2026-08-11T00:00:00Z' }) - mocks.getSkillSync.mockResolvedValue({ id: 9, status: 'succeeded', progress_stage: 'verified', prompt_capture_provided: false, candidate_bundle_version_id: 31, created_at: '2026-08-11T00:00:00Z' }) - const publishedRegistry = skillRegistry() - publishedRegistry.runtime = { ...publishedRegistry.runtime, active: candidate, active_prompt: candidate.prompt, revision: 4 } - publishedRegistry.versions = [candidate, ...publishedRegistry.versions] - mocks.getSkillRegistry - .mockResolvedValueOnce(skillRegistry()) - .mockResolvedValueOnce(skillRegistry()) - .mockResolvedValue(publishedRegistry) - mocks.getSkillVersion.mockImplementation(async (id: number) => id === 30 ? skillVersionDetail() : candidate) - mocks.publishSkillVersion.mockResolvedValue({ ...skillRegistry().runtime, active: candidate, active_prompt: candidate.prompt, revision: 4 }) - - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click') - await flushPromises() - vi.useFakeTimers() - try { - await wrapper.get('[data-test="system-prompt-skill-sync"]').trigger('click') - await vi.advanceTimersByTimeAsync(1200) - await flushPromises() - - const candidatePanel = wrapper.get('[data-test="system-prompt-skill-candidate"]') - expect(candidatePanel.text()).toContain('Raw tree hash') - expect(candidatePanel.text()).toContain('Effective tree hash') - expect(candidatePanel.text()).toContain('Raw prompt hash') - expect(candidatePanel.text()).toContain('Effective prompt hash') - expect(candidatePanel.text()).toContain('Fetched at') - expect(candidatePanel.text()).toContain('Exact prompt diff') - expect(candidatePanel.get('[data-test="system-prompt-skill-prompt-diff"]').text()).toContain('@@ routing') - expect(candidatePanel.text()).toContain('SKILL.md') - expect(mocks.publishSkillVersion).not.toHaveBeenCalled() - - await candidatePanel.get('[data-test="system-prompt-skill-publish-candidate"]').trigger('click') - await flushPromises() - expect(mocks.publishSkillVersion).not.toHaveBeenCalled() - expect(wrapper.get('[data-test="system-prompt-skill-confirm"]').exists()).toBe(true) - await wrapper.get('[data-test="system-prompt-skill-confirm-action"]').trigger('click') - await flushPromises() - expect(mocks.publishSkillVersion).toHaveBeenCalledWith(31, 3, false) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'candidate effective body') - } finally { - vi.useRealTimers() - } + it('retains a content-only draft when another administrator deleted its rule', async () => { + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="system-prompt-body"]').setValue('Locally edited after remote deletion') + const remote = promptConfig(); remote.revision = 8; remote.policy.rules = remote.policy.rules.filter(rule => rule.id !== 'first'); remote.policy.default_rule_ids = []; delete remote.contents.first + api.config.mockResolvedValue(remote) + await wrapper.get('[data-test="system-prompt-refresh"]').trigger('click'); await flushPromises() + await wrapper.get('[data-test="keep-prompt-draft"]').trigger('click') + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Locally edited after remote deletion') + api.saveConfig.mockImplementation(async payload => ({ ...remote, revision: 9, policy: payload.policy, contents: { ...remote.contents, first: { ...promptConfig().contents.first!, body: payload.contents.first.body } } })) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ expected_revision: 8, contents: { first: { body: 'Locally edited after remote deletion' } }, policy: expect.objectContaining({ rules: expect.arrayContaining([expect.objectContaining({ id: 'first', template_id: 1 })]) }) })) }) - - it('loads the selected template from the mobile selector', async () => { - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-mobile-template"]').setValue('2') - await flushPromises() - - expect(mocks.get).toHaveBeenLastCalledWith(2) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'seed') - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('readOnly', false) - expect(wrapper.get('[data-test="system-prompt-tab-history"]').exists()).toBe(true) + it('preserves source-managed text and copies it into an independent draft', async () => { + const config = promptConfig(); config.contents.first!.managed = true + api.config.mockResolvedValue(config) + const wrapper = mountView(); await flushPromises() + expect(wrapper.get('[data-test="system-prompt-body"]').attributes('readonly')).toBeDefined() + await wrapper.get('[data-test="duplicate-prompt"]').trigger('click') + expect(wrapper.get('[data-test="system-prompt-body"]').attributes('readonly')).toBeUndefined() + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'First body') + expect(wrapper.get('[data-test="platform-openai"]').element).toHaveProperty('checked', false) + expect(api.saveConfig).not.toHaveBeenCalled() }) - - it('keeps template creation reachable beside the mobile selector', async () => { - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-mobile-create"]').trigger('click') - - expect(wrapper.text()).toContain('Identifier') + it('previews unsaved content and reports the final fields and missing user anchor', async () => { + api.preview.mockResolvedValue({ requested_model: 'alias', upstream_model: 'mapped', protocol: 'responses', transport: 'http', body: { instructions: 'Client instructions', input: [{ role: 'developer', content: 'Unsent' }] }, before_rules: {}, client_control: {}, gateway_base_instructions: '', simulated: true, wire_verified: true, application: { applied: true, rules_plan: { placements: [{ rule_id: 'first', position: 'conversation_tail', carrier: 'input', role: 'developer', body: 'Unsent', index: 1 }], skipped: [{ rule_id: 'second', reason: 'last_user_missing' }] } } }) + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="system-prompt-body"]').setValue('Unsent') + await wrapper.get('input[type="number"]').setValue(42) + await wrapper.get('[data-test="run-prompt-preview"]').trigger('click'); await flushPromises() + expect(api.preview).toHaveBeenCalledWith(42, expect.objectContaining({ contents: { first: { body: 'Unsent' } }, policy: expect.objectContaining({ version: 2 }) })) + expect(wrapper.text()).toContain('role=developer [1]') + expect(wrapper.text()).toContain('No user message in this outbound request') + expect(wrapper.text()).toContain('Top-level instructions') + expect(wrapper.text()).toContain('Outbound message array') + expect(api.saveConfig).not.toHaveBeenCalled() }) - - it('saves a new version separately from setting it current', async () => { - mocks.saveDraft.mockResolvedValue({ ...version(2), id: 21, version: 2, body: 'draft', note: 'seed', is_active: false }) - mocks.publish.mockResolvedValue({ ...runtime(), template_id: 2, version_id: 21, template_version: 2, revision: 6 }) - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click') - await flushPromises() - await wrapper.get('[data-test="system-prompt-body"]').setValue('draft') - await wrapper.get('[data-test="system-prompt-save-version"]').trigger('click') - await flushPromises() - - expect(mocks.saveDraft).toHaveBeenCalledWith(2, expect.objectContaining({ body: 'draft', expected_latest_version: 1, expected_revision: 5 })) - expect(mocks.publish).not.toHaveBeenCalled() - await wrapper.get('[data-test="system-prompt-set-current"]').trigger('click') - await wrapper.get('[data-test="confirm-action"]').trigger('click') - await flushPromises() - expect(mocks.publish).toHaveBeenCalledWith(2, 21, 5, false) + it('restores a historical reference for the selected prompt through the unified save', async () => { + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="open-prompt-advanced"]').trigger('click'); await flushPromises() + await wrapper.get('[data-test="restore-version-9"]').trigger('click') + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Historical body') + api.saveConfig.mockImplementation(async payload => ({ ...promptConfig(), revision: 8, policy: payload.policy, contents: { ...promptConfig().contents, first: { ...promptConfig().contents.first!, body: 'Historical body', version_id: 9 } } })) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ contents: {}, policy: expect.objectContaining({ rules: [expect.objectContaining({ id: 'first', version_id: 9 }), expect.objectContaining({ id: 'second', version_id: 20 })] }) })) }) - - it('creates an inactive managed-source candidate without activating it', async () => { - mocks.syncManagedSource.mockResolvedValue({ status: 'candidate_created', version: { ...version(2), id: 21, is_active: false, version: 2 } }) - const wrapper = mountView() - await flushPromises() - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click') - await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click') - await flushPromises() - await wrapper.get('[data-test="system-prompt-source-sync"]').trigger('click') - await flushPromises() - - expect(mocks.syncManagedSource).toHaveBeenCalledWith(2, { expected_latest_version: 1, expected_revision: 5 }) - expect(mocks.publish).not.toHaveBeenCalled() - expect(wrapper.get('[data-test="system-prompt-source-candidate"]').exists()).toBe(true) + it('shows every affected prompt before paired Skill publication and sends explicit targets', async () => { + const config = promptConfig() + config.policy.rules[1]!.template_id = 1 + config.contents.first!.managed = true + config.contents.second!.managed = true + config.contents.first!.composition_mode = 'codex_skill_hybrid' + config.contents.second!.composition_mode = 'codex_skill_hybrid' + api.config.mockResolvedValue(config) + api.list.mockResolvedValue({ templates: [{ ...template, managed_source: 'remote_skill_registry' }] }) + api.get.mockResolvedValue({ template: { ...template, managed_source: 'remote_skill_registry' }, versions: [version], runtime: {} }) + api.getSkillRegistry.mockResolvedValue({ runtime: { revision: 3, active: { id: 30, raw_tree_sha256: 'raw', effective_tree_sha256: 'tree', effective_total_bytes: 10, file_count: 1 } }, versions: [{ id: 29, effective_tree_sha256: 'older-tree', file_count: 1 }], source: {} }) + api.publishSkillVersion.mockResolvedValue({}) + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="open-prompt-advanced"]').trigger('click'); await flushPromises() + expect(wrapper.get('[data-test="skill-target-rules"]').text()).toContain('First prompt, Second prompt') + await wrapper.get('[title="Roll back"]').trigger('click') + expect(api.publishSkillVersion).not.toHaveBeenCalled() + await wrapper.get('[data-test="system-prompt-skill-confirm-action"]').trigger('click'); await flushPromises() + expect(api.publishSkillVersion).toHaveBeenCalledWith(29, 3, true, { target_rule_ids: ['first', 'second'], expected_config_revision: 7 }) }) - - describe('editor request ownership', () => { - it('keeps post-send body and note edits against the saved version without resubmitting or publishing', async () => { - const save = deferred>() - mocks.saveDraft.mockReturnValue(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click'); await flushPromises() - await wrapper.get('[data-test="system-prompt-body"]').setValue('sent body') - await wrapper.get('[aria-label="Version note"]').setValue('sent note') - await wrapper.get('[data-test="system-prompt-save-version"]').trigger('click') - wrapper.get('[data-test="system-prompt-save-version"]').element.dispatchEvent(new MouseEvent('click', { bubbles: true })) - await wrapper.get('[data-test="system-prompt-body"]').setValue('later body') - await wrapper.get('[aria-label="Version note"]').setValue('later note') - save.resolve({ ...version(2), id: 21, version: 2, body: 'sent body', note: 'sent note', is_active: false }); await flushPromises() - - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'later body') - expect(wrapper.get('[aria-label="Version note"]').element).toHaveProperty('value', 'later note') - expect(mocks.saveDraft).toHaveBeenCalledTimes(1) - expect(mocks.saveDraft.mock.calls[0]).toEqual([2, expect.objectContaining({ body: 'sent body', note: 'sent note', expected_latest_version: 1, expected_revision: 5 })]) - expect(wrapper.get('[data-test="system-prompt-set-current"]').attributes('disabled')).toBeDefined() - expect(mocks.publish).not.toHaveBeenCalled() - mocks.saveDraft.mockResolvedValueOnce({ ...version(2), id: 22, version: 3, body: 'later body', note: 'later note', is_active: false }) - await wrapper.get('[data-test="system-prompt-save-version"]').trigger('click'); await flushPromises() - expect(mocks.saveDraft.mock.calls[1]).toEqual([2, expect.objectContaining({ body: 'later body', note: 'later note', expected_latest_version: 2, expected_revision: 5 })]) - expect(wrapper.get('[data-test="system-prompt-save-version"]').attributes('disabled')).toBeDefined() - wrapper.unmount() - }) - - it('does not attach a saved version to a template selected after the request began', async () => { - const save = deferred>() - mocks.saveDraft.mockReturnValueOnce(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click'); await flushPromises() - await wrapper.get('[data-test="system-prompt-body"]').setValue('sent body') - await wrapper.get('[data-test="system-prompt-save-version"]').trigger('click') - // Return to the unchanged baseline so the existing dirty-selection - // contract permits this explicit switch; no new discard rule is assumed. - await wrapper.get('[data-test="system-prompt-body"]').setValue('seed') - await wrapper.get('[data-test="system-prompt-template-1"]').trigger('click'); await flushPromises() - save.resolve({ ...version(2), id: 21, version: 2, body: 'sent body', is_active: false }); await flushPromises() - - expect(wrapper.getComponent(SystemPromptAdvancedDrawer).props('sourceTemplate')).toMatchObject({ id: 1 }) - expect(wrapper.getComponent(SystemPromptAdvancedDrawer).props('sourceVersion')).toMatchObject({ id: 10, template_id: 1 }) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') - expect(mocks.publish).not.toHaveBeenCalled() - expect(mocks.showSuccess).toHaveBeenCalledWith('A new prompt version has been saved.') - wrapper.unmount() - }) - - it.each(['success', 'error'])('ignores an older template %s while a later selection is loading', async (outcome) => { - mocks.list.mockResolvedValue({ templates: [codexTemplate(), gptTemplate(), thirdTemplate()], runtime: runtime() }) - const wrapper = mountView(); await flushPromises() - const old = deferred>(), current = deferred>() - mocks.get.mockImplementation((id: number) => id === 2 ? old.promise : current.promise) - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click') - await wrapper.get('[data-test="system-prompt-template-3"]').trigger('click') - if (outcome === 'success') old.resolve(templateDetail(2)) - else old.reject(new Error('old template error')) - await flushPromises() - - expect(wrapper.get('[data-test="system-prompt-refresh"]').attributes('disabled')).toBeDefined() - expect(mocks.showError).not.toHaveBeenCalled() - current.resolve(templateDetail(3)); await flushPromises() - expect(wrapper.getComponent(SystemPromptAdvancedDrawer).props('sourceTemplate')).toMatchObject({ id: 3 }) - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'third body') - expect(wrapper.get('[data-test="system-prompt-refresh"]').attributes('disabled')).toBeUndefined() - wrapper.unmount() - }) - - it('does not let an older list refresh select its old template after a newer explicit choice', async () => { - const wrapper = mountView(); await flushPromises() - const list = deferred<{ templates: ReturnType[]; runtime: ReturnType }>() - mocks.list.mockReturnValueOnce(list.promise) - await wrapper.get('[data-test="system-prompt-refresh"]').trigger('click') - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click'); await flushPromises() - list.resolve({ templates: [codexTemplate(), gptTemplate()], runtime: runtime() }); await flushPromises() - expect(wrapper.getComponent(SystemPromptAdvancedDrawer).props('sourceTemplate')).toMatchObject({ id: 2 }) - expect(mocks.get.mock.calls.map(call => call[0])).toEqual([1, 2]) - wrapper.unmount() - }) - - it('does not show an old managed-body response in a reopened managed selection', async () => { - const old = deferred>(), current = deferred>() - mocks.getSkillVersion.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="system-prompt-template-2"]').trigger('click'); await flushPromises() - await wrapper.get('[data-test="system-prompt-template-1"]').trigger('click'); await flushPromises() - old.resolve({ ...skillVersionDetail(), prompt: { ...skillVersionDetail().prompt, effective_body: 'old managed body' } }); await flushPromises() - expect(wrapper.find('[data-test="system-prompt-managed-loading"]').exists()).toBe(true) - expect(wrapper.find('[data-test="system-prompt-body"]').exists()).toBe(false) - current.resolve(skillVersionDetail()); await flushPromises() - expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') - wrapper.unmount() + it('subscribes a new prompt to the current paired Skill without copying an old scaffold into a new version', async () => { + const source = { ...template, id: 3, managed_source: 'remote_skill_registry' } + api.list.mockResolvedValue({ templates: [template, source] }) + api.get.mockImplementation(async id => id === 3 ? { template: source, versions: [{ ...version, id: 31, template_id: 3, body: 'Old scaffold', composition_mode: 'codex_skill_hybrid' }], runtime: {} } : { template, versions: [version], runtime: {} }) + api.getSkillRegistry.mockResolvedValue({ runtime: { revision: 3, active: { id: 30, effective_total_bytes: 10 } }, versions: [], source: {} }) + api.getSkillVersion.mockResolvedValue({ id: 30, prompt: { effective_body: 'Current paired prompt' } }) + const wrapper = mountView(); await flushPromises() + await wrapper.get('[data-test="open-prompt-advanced"]').trigger('click'); await flushPromises() + await wrapper.get('[data-test="subscribe-current-skill"]').trigger('click'); await flushPromises() + expect(wrapper.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'Current paired prompt') + expect(wrapper.get('[data-test="system-prompt-body"]').attributes('readonly')).toBeDefined() + expect(wrapper.get('[data-test="prompt-role"]').element).toHaveProperty('value', 'auto') + expect(wrapper.get('[data-test="platform-openai"]').element).toHaveProperty('checked', false) + await wrapper.get('[data-test="platform-openai"]').setValue(true) + api.saveConfig.mockImplementation(async payload => { + const added = payload.policy.rules.find((rule: { template_id: number }) => rule.template_id === 3) + return { ...promptConfig(), revision: 8, policy: payload.policy, contents: { ...promptConfig().contents, [added.id]: { body: 'Current paired prompt', template_id: 3, version_id: 31, composition_mode: 'codex_skill_hybrid', managed: true } } } }) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ contents: {}, policy: expect.objectContaining({ rules: expect.arrayContaining([expect.objectContaining({ template_id: 3, version_id: 31, role: 'auto', position: 'control_append', platforms: ['openai'] })]) }) })) + expect(api.publishSkillVersion).not.toHaveBeenCalled() }) - - describe('sync polling lifetime', () => { - it('does not schedule a poll from a sync-start response arriving after unmount', async () => { - const start = deferred<{ id: number; status: string }>() - mocks.startSkillSync.mockReturnValueOnce(start.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click'); await flushPromises() - vi.useFakeTimers() - try { - await wrapper.get('[data-test="system-prompt-skill-sync"]').trigger('click'); await flushPromises() - wrapper.unmount() - start.resolve({ id: 9, status: 'queued' }); await flushPromises() - await vi.advanceTimersByTimeAsync(2400) - expect(mocks.startSkillSync).toHaveBeenCalledTimes(1) - expect(mocks.getSkillSync).not.toHaveBeenCalled() - expect(mocks.publishSkillVersion).not.toHaveBeenCalled() - const reopened = mountView(); await flushPromises() - expect(reopened.get('[data-test="system-prompt-body"]').element).toHaveProperty('value', 'server effective body') - expect(mocks.startSkillSync).toHaveBeenCalledTimes(1) - reopened.unmount() - } finally { vi.useRealTimers() } - }) - - it.each(['running', 'succeeded'])('does not continue a pending %s poll after unmount', async (status) => { - const poll = deferred<{ id: number; status: string; candidate_bundle_version_id?: number }>() - mocks.getSkillSync.mockReturnValueOnce(poll.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="system-prompt-open-advanced"]').trigger('click'); await flushPromises() - vi.useFakeTimers() - try { - await wrapper.get('[data-test="system-prompt-skill-sync"]').trigger('click') - await vi.advanceTimersByTimeAsync(1200) - expect(mocks.getSkillSync).toHaveBeenCalledTimes(1) - wrapper.unmount() - poll.resolve({ id: 8, status, candidate_bundle_version_id: status === 'succeeded' ? 31 : undefined }); await flushPromises() - await vi.advanceTimersByTimeAsync(2400) - expect(mocks.getSkillSync).toHaveBeenCalledTimes(1) - expect(mocks.getSkillVersion).toHaveBeenCalledTimes(1) - expect(mocks.getSkillRegistry).toHaveBeenCalledTimes(1) - expect(mocks.publishSkillVersion).not.toHaveBeenCalled() - expect(mocks.showSuccess).not.toHaveBeenCalled() - } finally { vi.useRealTimers() } - }) + it('shows an unavailable managed source while allowing its settings and global switch to be saved', async () => { + const config = promptConfig(); config.contents.first = { ...config.contents.first!, body: '', managed: true, composition_mode: 'codex_skill_hybrid', available: false } + api.config.mockResolvedValue(config) + api.saveConfig.mockImplementation(async payload => ({ ...config, revision: 8, enabled: payload.enabled, policy: payload.policy })) + const wrapper = mountView(); await flushPromises() + expect(wrapper.get('[data-test="managed-source-unavailable"]').text()).toContain('current paired source is unavailable') + expect(wrapper.find('[data-test="system-prompt-body"]').exists()).toBe(false) + expect(wrapper.get('[data-test="duplicate-prompt"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="prompt-name"]').setValue('Unavailable source') + await wrapper.get('[data-test="system-prompt-global-toggle"] input').setValue(false) + await wrapper.get('[data-test="save-rules"]').trigger('click'); await flushPromises() + expect(api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, contents: {}, policy: expect.objectContaining({ rules: expect.arrayContaining([expect.objectContaining({ name: 'Unavailable source' })]) }) })) }) }) diff --git a/frontend/src/views/admin/__tests__/systemPromptFixtures.ts b/frontend/src/views/admin/__tests__/systemPromptFixtures.ts new file mode 100644 index 000000000000..e67a0d3568be --- /dev/null +++ b/frontend/src/views/admin/__tests__/systemPromptFixtures.ts @@ -0,0 +1,26 @@ +import type { PromptCapability, PromptConfig, PromptPosition, PromptRole } from '@/api/admin/systemPromptRules' + +const positions: PromptPosition[] = ['control_prepend', 'control_append', 'conversation_head', 'conversation_tail', 'before_last_user', 'after_last_user'] +const roles: PromptRole[] = ['auto', 'system', 'developer'] +export const capabilities: PromptCapability[] = [ + { protocol: 'responses', platforms: ['openai', 'cindy'], roles, positions_by_role: Object.fromEntries(roles.map(role => [role, positions])), limitations: ['oauth_system_unsupported'] }, + { protocol: 'chat', platforms: ['openai', 'cindy', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'], roles, positions_by_role: Object.fromEntries(roles.map(role => [role, positions])), limitations: [] }, + { protocol: 'messages', platforms: ['anthropic', 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'], roles: ['auto', 'system'], positions_by_role: { auto: ['control_prepend', 'control_append', 'conversation_tail', 'after_last_user'], system: ['control_prepend', 'control_append', 'conversation_tail', 'after_last_user'] }, limitations: [], conversation_system_models: ['claude-opus-5'] }, + { protocol: 'gemini', platforms: ['gemini', 'antigravity'], roles: ['auto', 'system'], positions_by_role: { auto: ['control_prepend', 'control_append'], system: ['control_prepend', 'control_append'] }, limitations: [] }, +] +export function promptConfig(): PromptConfig { + return { + revision: 7, enabled: true, expose_server_prompt: false, compact_enabled: false, + policy: { version: 2, default_rule_ids: ['first'], rules: [ + { id: 'first', name: 'First prompt', enabled: true, template_id: 1, version_id: 10, order: 100, role: 'auto', position: 'control_append', platforms: ['openai'], model_match: 'upstream', models: [] }, + { id: 'second', name: 'Second prompt', enabled: true, template_id: 2, version_id: 20, order: 200, role: 'auto', position: 'control_append', platforms: ['gemini'], model_match: 'upstream', models: [] }, + ] }, + contents: { + first: { body: 'First body', template_id: 1, version_id: 10, composition_mode: 'inline', managed: false }, + second: { body: 'Second body', template_id: 2, version_id: 20, composition_mode: 'inline', managed: false }, + }, + capabilities, + } +} +export const template = { id: 1, slug: 'first', name: 'First source', description: '', managed_source: '', is_seed: false, created_at: '', updated_at: '' } +export const version = { id: 9, template_id: 1, version: 1, body: 'Historical body', sha256: 'abc', byte_length: 15, note: 'Original note', composition_mode: 'inline', bundle_id: '', bundle_manifest_sha256: '', created_at: '' } diff --git a/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts b/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts index 7bae7af10758..9f06167f7e16 100644 --- a/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts +++ b/frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts @@ -25,9 +25,11 @@ describe('System Prompts integration surface', () => { it('renders the native management page without legacy surfaces', () => { const view = read('../SystemPromptsView.vue') expect(view).not.toContain('ExtensionPage') - for (const marker of ['saveVersion', 'setCurrent', 'rollback', 'syncManagedSource', 'SystemPromptAdvancedDrawer']) { + for (const marker of ['saveConfig', 'useSystemPromptConfigDraft', 'syncManagedSource', 'SystemPromptAdvancedDrawer']) { expect(view).toContain(marker) } + expect(view).not.toContain('setCurrent') + expect(view).not.toContain('saveDraft') expect(view).not.toContain('previewMerge') expect(view).not.toContain('previewUpstream') expect(view).not.toContain('DOMPurify') From 0f623460b569021f1a61699cb33db7dc64e6e7df Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 13:30:33 +0800 Subject: [PATCH 2/2] fix(prompts): retire obsolete adapters and satisfy lint --- ...siness_system_prompt_account_scope_test.go | 3 ++- .../service/business_system_prompt_policy.go | 10 ---------- .../service/gateway_claude_oauth_body.go | 19 ++----------------- .../gateway_context_management_test.go | 6 ++++-- .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- .../service/gateway_system_prompt_preview.go | 2 +- .../service/gemini_messages_compat_service.go | 6 +++--- .../service/openai_chat_reasoning_replay.go | 10 ---------- .../internal/service/plugin_prompt_test.go | 14 ++++++++++---- .../internal/service/prompt_skills_module.go | 6 ------ 11 files changed, 24 insertions(+), 56 deletions(-) diff --git a/backend/internal/service/business_system_prompt_account_scope_test.go b/backend/internal/service/business_system_prompt_account_scope_test.go index cb86599f5564..1f9fc3195c20 100644 --- a/backend/internal/service/business_system_prompt_account_scope_test.go +++ b/backend/internal/service/business_system_prompt_account_scope_test.go @@ -118,7 +118,8 @@ func TestPromptEchoProofPreservesIdenticalClientMessageAtNewPositions(t *testing require.NoError(t, err) stateRaw, ok := businessSystemPromptRequestGet(c, businessSystemPromptContextKey(c, businessSystemPromptRequestApplicationKey, "responses")) require.True(t, ok) - state := stateRaw.(businessSystemPromptRequestState) + state, ok := stateRaw.(businessSystemPromptRequestState) + require.True(t, ok) require.Len(t, state.rulesUndo, 1) require.Empty(t, state.rulesUndo[0].scalar, "large client histories are not copied into echo metadata") require.Equal(t, []int{1}, state.rulesUndo[0].indices) diff --git a/backend/internal/service/business_system_prompt_policy.go b/backend/internal/service/business_system_prompt_policy.go index f48c3d016f65..3aeec411b483 100644 --- a/backend/internal/service/business_system_prompt_policy.go +++ b/backend/internal/service/business_system_prompt_policy.go @@ -98,16 +98,6 @@ func PlanBusinessSystemPrompt(ctx context.Context, snapshot BusinessSystemPrompt return planBusinessSystemPrompt(ctx, snapshot, target) } -// Compatibility wrappers for internal callers while request lifecycle adapters -// migrate. Planning is local and never invokes the legacy prompt.plan RPC. -func applyBusinessSystemPromptWithInvoker(ctx context.Context, body []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, _ func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) ([]byte, BusinessSystemPromptApplication, error) { - return ApplyBusinessSystemPromptToJSONContext(ctx, body, snapshot, target) -} - -func planBusinessSystemPromptWithInvoker(ctx context.Context, _ []byte, snapshot BusinessSystemPromptSnapshot, target BusinessSystemPromptTarget, _ func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error)) (BusinessSystemPromptApplication, error) { - return planBusinessSystemPrompt(ctx, snapshot, target) -} - func applyBusinessSystemPromptApplication(body []byte, application BusinessSystemPromptApplication) ([]byte, BusinessSystemPromptApplication, error) { if application.RulesPlan != nil { return applyPromptRules(body, application) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 95f60b5d155d..a439ac57d8a4 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -356,7 +356,7 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) } -// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" +// prepareClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" // 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。 // // 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode + @@ -374,22 +374,7 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account // - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 // - model:最终会发给上游的模型 ID(用于模型规范化 + metadata 版本选择)。 // -// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 -func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( - ctx context.Context, - c *gin.Context, - account *Account, - body []byte, - systemRaw any, - model string, -) []byte { - prepared, err := s.prepareClaudeCodeOAuthMimicryToBody(ctx, c, account, body, systemRaw, model) - if err != nil { - return body - } - return prepared -} - +// 返回改写后的 body;准备失败会返回错误,由调用方停止本次发送。 func (s *GatewayService) prepareClaudeCodeOAuthMimicryToBody( ctx context.Context, c *gin.Context, diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 60dc89489c48..13c8add7b3c2 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -376,9 +376,10 @@ func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) { body := []byte(`{"model":"claude-haiku-4-5","system":"Pi project instructions","messages":[{"role":"user","content":"hello"}]}`) svc := &GatewayService{cfg: &config.Config{}} - out := svc.applyClaudeCodeOAuthMimicryToBody( + out, err := svc.prepareClaudeCodeOAuthMimicryToBody( context.Background(), nil, account, body, "Pi project instructions", "claude-haiku-4-5", ) + require.NoError(t, err) system := gjson.GetBytes(out, "system").Array() require.Len(t, system, 3) @@ -393,9 +394,10 @@ func TestApplyClaudeCodeOAuthMimicryToBody_FableOmitsRefusedExpansion(t *testing body := []byte(`{"model":"claude-fable-5","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`) svc := &GatewayService{cfg: &config.Config{}} - out := svc.applyClaudeCodeOAuthMimicryToBody( + out, err := svc.prepareClaudeCodeOAuthMimicryToBody( context.Background(), nil, account, body, "Project instructions", "claude-fable-5", ) + require.NoError(t, err) system := gjson.GetBytes(out, "system").Array() require.Len(t, system, 2) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 65d027ea0712..13693f1e7058 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -102,7 +102,7 @@ func (s *GatewayService) ForwardAsChatCompletions( // Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 - // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + // 见 prepareClaudeCodeOAuthMimicryToBody 的 godoc。 isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 5368ce40f9f1..35102340d62b 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -112,7 +112,7 @@ func (s *GatewayService) ForwardAsResponses( // OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 - // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + // 见 prepareClaudeCodeOAuthMimicryToBody 的 godoc。 isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode diff --git a/backend/internal/service/gateway_system_prompt_preview.go b/backend/internal/service/gateway_system_prompt_preview.go index e4b810e9ce14..2e0579f9387a 100644 --- a/backend/internal/service/gateway_system_prompt_preview.go +++ b/backend/internal/service/gateway_system_prompt_preview.go @@ -138,7 +138,7 @@ func PreparePromptGatewayPreview(ctx context.Context, c *gin.Context, account *A mimic := account.IsAnthropicOAuthOrSetupToken() if mimic && c != nil && c.Request != nil { metadata := gjson.GetBytes(body, "metadata.user_id").String() - mimic = !IsClaudeCodeClient(ctx) && !isClaudeCodeClient(c.GetHeader("User-Agent"), metadata) && !(metadata != "" && systemHasBillingAttributionBlock(body)) + mimic = !IsClaudeCodeClient(ctx) && !isClaudeCodeClient(c.GetHeader("User-Agent"), metadata) && (metadata == "" || !systemHasBillingAttributionBlock(body)) } setBusinessSystemPromptRequestProfile(c, account, mimic) if mimic { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 74ac2348d1aa..2433751bc0c7 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -2807,7 +2807,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) // Keepalive / done markers if payload == "" || payload == "[DONE]" { - _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) + _, _ = c.Writer.Write(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini")) flusher.Flush() } else { var rawToWrite string @@ -2844,7 +2844,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", rewritePromptRulesStructuredEcho(c, []byte(rawToWrite), "gemini")) } else { // Pass-through for AI Studio responses. - _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) + _, _ = c.Writer.Write(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini")) } flusher.Flush() } @@ -2852,7 +2852,7 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte if !sawDataEvent { fallback.AddLine(trimmed) } - _, _ = io.WriteString(c.Writer, string(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini"))) + _, _ = c.Writer.Write(rewritePromptRulesStructuredSSE(c, []byte(line), "gemini")) flusher.Flush() } } diff --git a/backend/internal/service/openai_chat_reasoning_replay.go b/backend/internal/service/openai_chat_reasoning_replay.go index 641fc108a358..e1bef3c36d5a 100644 --- a/backend/internal/service/openai_chat_reasoning_replay.go +++ b/backend/internal/service/openai_chat_reasoning_replay.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "io" "net/http" "slices" "strings" @@ -722,12 +721,3 @@ func openAIChatChunksHaveSemanticOutput(chunks []apicompat.ChatCompletionsChunk) func openAIChatReasoningReplayError(err error) error { return fmt.Errorf("prepare chat reasoning replay request: %w", err) } - -func cloneOpenAIChatRequestWithBody(request *http.Request, body []byte) *http.Request { - cloned := request.Clone(request.Context()) - snapshot := bytes.Clone(body) - cloned.Body = io.NopCloser(bytes.NewReader(snapshot)) - cloned.ContentLength = int64(len(snapshot)) - cloned.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(snapshot)), nil } - return cloned -} diff --git a/backend/internal/service/plugin_prompt_test.go b/backend/internal/service/plugin_prompt_test.go index f3bdefb49b45..36808c402f7c 100644 --- a/backend/internal/service/plugin_prompt_test.go +++ b/backend/internal/service/plugin_prompt_test.go @@ -18,11 +18,14 @@ func TestPromptNativePlanDoesNotInvokeRPC(t *testing.T) { body := []byte(`{"instructions":"client","input":"` + history + `"}`) snapshot := unifiedPromptSnapshot(t, "openai", []string{"auto"}, []string{"control_append"}, []string{"server"}) invoked := 0 - updated, application, err := applyBusinessSystemPromptWithInvoker(context.Background(), body, snapshot, BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}, func(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { + previous := invokePromptSkills + t.Cleanup(func() { invokePromptSkills = previous }) + invokePromptSkills = func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { invoked++ t.Fatal("native planning invoked a plugin RPC") return extensionv1.Result{}, nil - }) + } + updated, application, err := ApplyBusinessSystemPromptToJSONContext(context.Background(), body, snapshot, BusinessSystemPromptTarget{Platform: PlatformOpenAI, Protocol: BusinessSystemPromptProtocolResponses}) require.NoError(t, err) require.Equal(t, 0, invoked) require.True(t, application.Applied) @@ -33,10 +36,13 @@ func TestPromptNativePlanDoesNotInvokeRPC(t *testing.T) { func TestPromptNativeCancellationUsesIncomingContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := applyBusinessSystemPromptWithInvoker(ctx, []byte(`{"input":"test"}`), BusinessSystemPromptSnapshot{Enabled: true, Body: "server"}, BusinessSystemPromptTarget{Platform: PlatformOpenAI}, func(call context.Context, _, _ string, _ extensionv1.Invocation) (extensionv1.Result, error) { + previous := invokePromptSkills + t.Cleanup(func() { invokePromptSkills = previous }) + invokePromptSkills = func(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { t.Fatal("canceled native planning invoked a plugin RPC") return extensionv1.Result{}, nil - }) + } + _, _, err := ApplyBusinessSystemPromptToJSONContext(ctx, []byte(`{"input":"test"}`), BusinessSystemPromptSnapshot{Enabled: true, Body: "server"}, BusinessSystemPromptTarget{Platform: PlatformOpenAI}) require.ErrorIs(t, err, ErrBusinessSystemPromptUnavailable) } diff --git a/backend/internal/service/prompt_skills_module.go b/backend/internal/service/prompt_skills_module.go index 2d6701b912da..4dba40de2658 100644 --- a/backend/internal/service/prompt_skills_module.go +++ b/backend/internal/service/prompt_skills_module.go @@ -17,9 +17,3 @@ var promptSkills = sync.OnceValue(promptpolicy.New) var invokePromptSkills = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { return promptSkills().Invoke(ctx, in) } - -// promptPlanInvoke adapts invokePromptSkills to the account-scoped invoker of -// the prompt application path. -func promptPlanInvoke(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { - return invokePromptSkills(ctx, in) -}