Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .downstream/codex-quality-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Codex 固定账号质量诊断

此入口用于管理员在真实 `/v1/responses` 链路上运行有界对照。路由材料、完整响应、上游模型声明和回答质量是四种不同证据;采集成功不代表回答质量恢复。

## 接口与范围

所有管理操作位于 `/api/v1/admin/accounts/:id/codex-quality-runs`,要求真实管理员身份。运行绑定该管理员已有的业务 Key、其分组、目标 Pro OAuth 账号主体、代理、`gpt-6-astra`、显式 `high` 和题面 SHA-256。

| 操作 | 请求 | 行为 |
| --- | --- | --- |
| `POST` 基础路径 | `run_id`、`api_key_id`、`prompt_sha256`、`max_sends`、`ttl_seconds` | 创建或恢复同一运行,换发短时 grant;最多 60 次发送、最长 7200 秒 |
| `GET /:run_id` | 无正文 | 读取预算、原始上游观测和私有路由资格 |
| `POST /:run_id/renew-route` | `operation_id` | 显式采集与业务出口复验,均计入同一预算 |
| `POST /:run_id/close` | 空对象 | 关闭 grant,释放该运行自身连接,擦除私有 Cookie 值,保留账本 |

`run_id`、`operation_id` 与 trial 均使用规范化 UUID。同一 run 恢复时不能改变绑定范围或增加预算,换发 grant 不恢复已花费次数;closed 运行不能重开。

业务仍用原 Key 作为 `Authorization: Bearer`,额外携带 `X-Sub2API-Quality-Grant` 和 `X-Sub2API-Quality-Trial`。正文的 `input` 必须为绑定的原题字符串,`model=gpt-6-astra`、`reasoning.effort=high`、`stream=true`、`store=false`。客户端会话元数据与 prompt cache key 可保留;不支持把此入口扩展为任意模型或任意账号转发。

grant 与 trial header 在 handler 开始时移入私有执行上下文,不转发给上游。错误、过期或已消费的授权直接失败,不回落到普通选号。

## 执行约束

- 只在私有选号判断中跳过账号手动停调度开关;账号、Key、分组和代理配置不修改。当前 Key 状态、到期、额度、渠道模型限制、账号健康、并发及路由资格仍检查。
- acquire、verify、business 共用持久 CAS 预算,在本地准备与检查结束、上游 I/O 开始前预留。传输结果未知保留为 unknown,不能重放该 trial;预留次数是保守的发送上限,不能宣称每次预留均已得到上游响应。
- 诊断材料使用独立 bundle、clock 与连接,不能覆盖普通资格,不登记自动续获。GET 不抢占或恢复普通 half-open probe。
- 普通健康、调度恢复、proxy circuit 和自动恢复通知与诊断隔离;实际用量账单及额度快照按真实请求记录。
- 记录来源是网关改写前的原始响应。`created_model`、`terminal_model`、HTTP/事件模型头分别保留;模型头冲突保存在 `header_models`。缺失 token 数据为 null,不替换为零。
- `completed` 仅代表成功终态观测,不能替代模型核验或数学判分。当前仍保留修前探针形态,探针未显式给出的 effort 如实记录为空。

## 运维执行器

项目运维目录使用 `scripts/invoke-codex-quality-acceptance.py`。它从既有配置读取管理员凭据,仅在内存保存访问令牌、业务 Key 和 grant。固定账本为 `artifacts/evidence/codex-quality-20260923.json`;跨日、客户端中断、换版本都不能换 run ID 或清空预算。

```powershell
python scripts/invoke-codex-quality-acceptance.py preflight
python scripts/invoke-codex-quality-acceptance.py local-status
python scripts/invoke-codex-quality-acceptance.py status
```

`baseline` 与 `verification` 为真实生成操作,必须传发布者核实的 `--version` 和 `--source-sha`。前者 6 次,后者 12 次;前两次只观察,修后计分序列跨至少 6 分钟与两次实际资格续获。所有采集、复验、失败和对照都受同一 60 次上限约束。生成期间由单一执行者持有本地账本锁,发布者不得同时替换容器。

`review` 只对已有 trial 登记数学结论,不调用模型。通过条件包括完整响应、固定账号与最终 high、最终原始模型匹配、全部已提供模型头一致,以及答案和证明均正确;不能用正则找到“21”便自动通过。题目基准为 9 圆+12 星,且需证明 20 不足。

`reconcile` 不发送模型请求。只有收到应用明确的发送前拒绝、服务器账本也无该 trial,才允许保留原记录并安排新 trial;网络超时或代理错误不能当作“没发送”。

## 发布次序

诊断基础设施先独立发布并采集修前基线。协议与账号身份修复随后发布,在共同宿主基线、同一账号和同一预算下完成修后序列。不能先应用修复再把结果命名为修前基线。

后续协议整合必须保持读取次序:网络 → 原始诊断观察 → 模型声明检查 → 业务响应改写。诊断业务流的读取限界需与普通业务一致,轻量采集探针保持自身预算。
2 changes: 1 addition & 1 deletion .downstream/gpt6-routing-and-prompts.md
Original file line number Diff line number Diff line change
@@ -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`(宿主范围放宽到 `<0.2.9`,下限不变,便于回退到 0.2.7 宿主)。官方 v0.2.8 自带 GPT-6 Sol/Luna 定价与识别,下游改用官方实现,只保留 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 专属的路由门控、指令模板、实时容量和日期快照不改名的约定。插件固定版本意图继续保留,旧提示词插件不能解释新规则时明确报告不可用。

## 模型同步与容量

Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/test_first_party_plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ go build -trimpath -o "$plugin_build_dir/package-plugin" ./cmd/package-plugin
-bundle-source ../plugins/bundle.source.json \
-binary-dir "$plugin_build_dir/binaries" \
-platform linux-amd64 \
-tested-host-version 0.2.8-codexrip.1 \
-tested-host-version 0.2.8-codexrip.2 \
-output "$plugin_build_dir/bundle" \
-signing-key-file "$plugin_build_dir/publisher.key" \
-key-id codexrip-plugins-test
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/downstream-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
run: bash .github/scripts/test_model_context_capacity.sh
- name: Service tests
working-directory: backend
run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|BusinessSystemPrompt|RemoteSkill|Cindy|CompatibilityModes|AlphaSearch)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1
run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|BusinessSystemPrompt|RemoteSkill|Cindy|CompatibilityModes|AlphaSearch|CodexQuality)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1
- name: Upstream automation classifier tests
run: |
python3 .github/scripts/test_upstream_risk.py
Expand Down
113 changes: 113 additions & 0 deletions backend/internal/handler/admin/account_codex_quality.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package admin

import (
"context"
"strconv"

"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 codexQualityAdmin(c *gin.Context) (int64, int64, bool) {
c.Header("Cache-Control", "no-store")
actor, ok := middleware.GetAuthSubjectFromContext(c)
role, roleOK := middleware.GetUserRoleFromContext(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if !ok || !roleOK || role != "admin" || actor.UserID <= 0 || err != nil || id <= 0 {
response.Error(c, 403, "Codex quality run unavailable")
return 0, 0, false
}
return actor.UserID, id, true
}

func writeCodexQualityResult(c *gin.Context, result *service.CodexQualityRunView, err error) {
if err != nil || result == nil {
response.Error(c, 409, "Codex quality run unavailable")
return
}
response.Success(c, result)
}

func (h *AccountHandler) CreateCodexQualityRun(c *gin.Context) {
actor, id, ok := codexQualityAdmin(c)
if !ok {
return
}
var req service.CodexQualityCreateRequest
if h.codexTicketGateway == nil || c.ShouldBindJSON(&req) != nil || req.APIKeyID <= 0 {
response.Error(c, 409, "Codex quality run unavailable")
return
}
// A global integration admin key resolves to a real administrator too. Only
// that administrator's own existing downstream key can authorize this run.
keys, _, err := h.adminService.GetUserAPIKeys(c.Request.Context(), actor, 1, 1000, "", "")
if err != nil {
response.Error(c, 409, "Codex quality run unavailable")
return
}
var key *service.APIKey
for index := range keys {
if keys[index].ID == req.APIKeyID && keys[index].UserID == actor {
key = &keys[index]
break
}
}
result, err := h.codexTicketGateway.CreateCodexQualityRun(c.Request.Context(), actor, id, key, req)
writeCodexQualityResult(c, result, err)
}

func (h *AccountHandler) ReadCodexQualityRun(c *gin.Context) {
actor, id, ok := codexQualityAdmin(c)
if !ok {
return
}
if h.codexTicketGateway == nil {
response.Error(c, 409, "Codex quality run unavailable")
return
}
result, err := h.codexTicketGateway.ReadCodexQualityRun(c.Request.Context(), actor, id, c.Param("run_id"))
writeCodexQualityResult(c, result, err)
}

func (h *AccountHandler) RenewCodexQualityRoute(c *gin.Context) {
actor, id, ok := codexQualityAdmin(c)
if !ok {
return
}
var req struct {
OperationID string `json:"operation_id"`
}
if h.codexTicketGateway == nil || c.ShouldBindJSON(&req) != nil {
response.Error(c, 409, "Codex quality run unavailable")
return
}
lookup := func(ctx context.Context, keyID int64) (*service.APIKey, error) {
keys, _, err := h.adminService.GetUserAPIKeys(ctx, actor, 1, 1000, "", "")
if err != nil {
return nil, service.ErrCodexQualityUnavailable
}
for index := range keys {
if keys[index].ID == keyID && keys[index].UserID == actor {
return &keys[index], nil
}
}
return nil, service.ErrCodexQualityUnavailable
}
result, err := h.codexTicketGateway.RenewCodexQualityRoute(c.Request.Context(), actor, id, c.Param("run_id"), req.OperationID, lookup)
writeCodexQualityResult(c, result, err)
}

func (h *AccountHandler) CloseCodexQualityRun(c *gin.Context) {
actor, id, ok := codexQualityAdmin(c)
if !ok {
return
}
if h.codexTicketGateway == nil {
response.Error(c, 409, "Codex quality run unavailable")
return
}
result, err := h.codexTicketGateway.CloseCodexQualityRun(c.Request.Context(), actor, id, c.Param("run_id"))
writeCodexQualityResult(c, result, err)
}
25 changes: 23 additions & 2 deletions backend/internal/handler/openai_gateway_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ func NewOpenAIGatewayHandler(
// Responses handles OpenAI Responses API endpoint
// POST /openai/v1/responses
func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
service.StageCodexQualityHeaders(c)
// 局部兜底:确保该 handler 内部任何 panic 都不会击穿到进程级。
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
Expand Down Expand Up @@ -546,6 +547,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
if err := h.gatewayService.AdmitCodexQualityRequest(c, apiKey, body, h.apiKeyService.GetByID); err != nil {
h.errorResponse(c, http.StatusConflict, "codex_quality_unavailable", "Codex quality run unavailable")
return
}

setOpsRequestContext(c, "", false)
sessionHashBody := body
Expand Down Expand Up @@ -923,7 +928,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
scheduleDecision service.OpenAIAccountScheduleDecision
err error
)
if retryingSameAccount {
if service.IsCodexQualityRequest(c.Request.Context()) {
selection, err = h.gatewayService.SelectCodexQualityAccount(c.Request.Context())
scheduleDecision.Layer = "codex_quality"
} else if retryingSameAccount {
selection, err = h.gatewayService.ReacquireOpenAISameAccountSelection(c.Request.Context(), sameAccountRetrySelection)
sameAccountRetrySelection = nil
scheduleDecision.Layer = "same_account_retry"
Expand Down Expand Up @@ -1037,6 +1045,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
accountReleaseFunc, slotResult = h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
}
if slotResult == openAISlotAcquireProfitVetoed {
if service.IsCodexQualityRequest(c.Request.Context()) {
h.handleStreamingAwareError(c, http.StatusConflict, "codex_quality_unavailable", "Codex quality run unavailable", streamStarted)
return
}
// 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错;
// 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
Expand Down Expand Up @@ -1121,6 +1133,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
stampOpenAIRequestedReasoningEffort(res, c)
usageSnapshot := snapshotOpenAIUsageMetadata(c, apiKey, account, subscription, channelMapping, reqModel, res, body)
usageInput := usageSnapshot.Input(res, h.apiKeyService, pricingAt)
usageInput.CodexQuality = service.IsCodexQualityRequest(c.Request.Context())
usageAPIKeyID := usageInput.APIKey.ID
usageGroupID := usageInput.APIKey.GroupID
usageAccountID := usageInput.Account.ID
Expand All @@ -1137,6 +1150,14 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
})
}
if err != nil && service.IsCodexQualityRequest(c.Request.Context()) {
h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection)
if shouldSubmitOpenAIUsage(err, result) {
submitResponsesUsage(result)
}
h.handleStreamingAwareError(c, http.StatusBadGateway, "codex_quality_failed", "Diagnostic attempt did not complete", streamStarted || c.Writer.Written())
return
}
if err != nil {
if result != nil && result.ClientDisconnect {
reqLog.Info("openai.client_disconnected",
Expand Down Expand Up @@ -2680,7 +2701,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlotWithFailureResponse(
selection.Account = latest
// 调度器已抢槽路径无门时由选号内部完成 eager 绑定;门下选号内部
// 推迟绑定,这里在终检通过后补准入后绑定。
if selection.ProfitGateActive() {
if selection.ProfitGateActive() && !service.IsCodexQualityRequest(ctx) {
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil {
reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/handler/openai_official_http_failover.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ func (h *OpenAIGatewayHandler) reportOpenAIHTTPAccountScheduleResult(
firstTokenMs *int,
observedErr error,
) {
if c != nil && c.Request != nil && service.IsCodexQualityRequest(c.Request.Context()) {
h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection)
return
}
if isOpenAIOfficialHTTPFailover(c, account) {
if !success && failoverClientGone(c) {
return
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/repository/codex_quality_breaker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package repository

import "context"

// A retained marker/claim is an ordinary half-open breaker. Diagnostics wait
// for its ordinary owner to recover it, rather than claiming or deleting it.
func (c *gatewayCache) CodexQualityRuntimeBlocked(ctx context.Context, accountID int64, models []string) (bool, error) {
var keys []string
for _, model := range normalizeOpenAIRuntimeBreakerModels(models) {
base := openAIRuntimeBreakerBaseKey(accountID, model)
keys = append(keys, base+":block", base+":marker", base+":claim")
}
if len(keys) == 0 {
return false, nil
}
count, err := c.rdb.Exists(ctx, keys...).Result()
return count > 0, err
}
21 changes: 21 additions & 0 deletions backend/internal/repository/codex_quality_lease.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package repository

import "time"

func (s *httpUpstreamService) HasCodexQualityConnection(id string, accountID int64, scope string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
lease := s.codexConnectionLeases[id]
return lease != nil && lease.accountID == accountID && lease.scope == scope && time.Now().Before(lease.expiresAt)
}

// Close only the connection whose complete ownership tuple belongs to this
// diagnostic run. Shared pools and other accounts' leases are never touched.
func (s *httpUpstreamService) CloseCodexQualityConnection(id string, accountID int64, scope string) {
s.mu.RLock()
lease := s.codexConnectionLeases[id]
s.mu.RUnlock()
if lease != nil && lease.accountID == accountID && lease.scope == scope {
s.removeCodexConnectionLease(id, lease)
}
}
Loading
Loading