chore: remove openspec and update axios

This commit is contained in:
shaw
2026-05-05 17:13:25 +08:00
parent ad9b88f0e3
commit dc05d4b250
19 changed files with 10 additions and 1059 deletions

View File

@@ -19,7 +19,7 @@
"@stripe/stripe-js": "^9.0.1",
"@tanstack/vue-virtual": "^3.13.23",
"@vueuse/core": "^10.7.0",
"axios": "^1.15.0",
"axios": "^1.16.0",
"chart.js": "^4.4.1",
"dompurify": "^3.3.1",
"driver.js": "^1.4.0",

View File

@@ -21,8 +21,8 @@ importers:
specifier: ^10.7.0
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
axios:
specifier: ^1.15.0
version: 1.15.0
specifier: ^1.16.0
version: 1.16.0
chart.js:
specifier: ^4.4.1
version: 4.5.1
@@ -1858,8 +1858,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.15.0:
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
axios@1.16.0:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -2534,8 +2534,8 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -6484,9 +6484,9 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
axios@1.15.0:
axios@1.16.0:
dependencies:
follow-redirects: 1.15.11
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -7228,7 +7228,7 @@ snapshots:
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
follow-redirects@1.16.0: {}
for-in@1.0.2: {}

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-04-29

View File

@@ -1,227 +0,0 @@
## Context
当前代码已经具备图片价格字段和部分图片转发能力,但边界不完整:
- `backend/ent/schema/group.go` 只有 `rate_multiplier``image_price_1k/2k/4k`,没有分组级生图能力开关,也没有“图片是否共享分组倍率”的开关。
- `backend/internal/handler/openai_images.go` 在解析 `/v1/images/*` 后只做通用余额/订阅资格检查,没有检查分组是否允许生图。
- `backend/internal/service/openai_gateway_service.go` 对 Codex CLI 会自动注入 `image_generation` tool通用 `/v1/responses` 只记录日志,没有把图片工具产物数量写入 `OpenAIForwardResult.ImageCount`
- `backend/internal/service/billing_service.go``CalculateImageCost` 当前使用 `image_price_* * image_count * rate_multiplier`。这个行为本身可以作为默认兼容模式,但普通编码分组 `rate_multiplier=0.15` 且希望图片最终价为 `0.2/张` 时,管理员必须填写 `image_price=0.2/0.15`,不可读且不适合长期运营。
- `backend/internal/service/openai_gateway_service.go``backend/internal/service/gateway_service.go` 的渠道图片计费路径当前传 `RequestCount: 1`,多图请求会按 1 次收费。
- `backend/internal/service/openai_images.go` 的 OpenAI 图片尺寸分层此前只覆盖少量固定尺寸;`gpt-image-2` 官方文档已经支持满足约束的自定义 `size`,因此本地计费必须能够对未知尺寸做稳定分档,同时不能因为本地映射不认识就提前拦截请求。
用户澄清后的业务要求是:普通编码分组可以关闭生图,也可以开启生图;开启后默认继续共享现有分组倍率以保持兼容,但管理员可以打开“生图倍率独立”开关,改用单独的图片倍率输入框。图片分组是推荐的运营隔离方式,但不是唯一承载方式。
## Goals / Non-Goals
**Goals:**
- 分组具备明确的 `allow_image_generation` 开关,所有已知生图入口在调度上游前执行同一个权限判断。
- 分组具备“生图倍率是否独立”的开关;默认 `false`,即共享当前代码里的有效分组倍率。
- 生图倍率独立开关打开后,图片费用使用单独的 `image_rate_multiplier`,不再使用普通编码分组的倍率。
- 保留现有 `image_price_1k/2k/4k` 字段作为图片单价配置,不强制把它们迁移成新的语义。
- 普通编码分组在 `allow_image_generation=false` 时仍可正常使用 `gpt-5.4` / `gpt-5.5` 文本能力,但不能使用图片工具。
- 普通编码分组在 `allow_image_generation=true` 时可使用 `gpt-5.4` / `gpt-5.5 + image_generation`,且按实际图片数量收费。
- 通用 `/v1/responses`、OpenAI Images API、流式、非流式、透传路径全部把成功产出的图片数量写入 `ImageCount`
- 渠道 `billing_mode=image` 使用真实 `ImageCount`,不再固定按 1 次收费。
**Non-Goals:**
- 不引入新的第三方依赖。
- 不改变 OpenAI 上游协议;只在现有请求转发、响应解析和计费归因层补齐控制。
- 不把“图片分组”做成唯一安全边界;分组开关和图片计费逻辑必须适用于任意开启生图的分组。
- 不在本变更中实现预扣费/资金冻结;失败请求仍不收费,成功请求按实际产物后扣费。
- 不改变默认历史图片价格行为;默认共享现有有效倍率,历史 `图片价格 * 分组/用户有效倍率` 的扣费行为保持。
- 不在本变更中新增用户级图片独立倍率覆盖;用户专属普通倍率只在共享倍率模式下继续影响图片。
## Decisions
### 0. 兼容性优先原则
本变更的默认行为必须以“不改变现有已配置分组的最终扣费”为优先级:
- 迁移不修改现有 `image_price_1k/2k/4k`
- 迁移把所有现有分组设置为 `image_rate_independent=false`,因此现有图片路径继续使用当前有效分组倍率。
- 管理员不传新字段更新分组时,不得覆盖已保存的 `allow_image_generation``image_rate_independent``image_rate_multiplier`
- 前端编辑旧分组时必须回显服务端值;不能因为表单默认值把旧分组从共享倍率误改成独立倍率,或把允许生图误改成禁止生图。
- 只有管理员显式打开 `image_rate_independent` 后,图片扣费才从共享倍率切换到图片独立倍率。
### 1. 分组字段与迁移策略
新增三个分组字段对应“2 个开关 + 1 个输入框”:
- `allow_image_generation BOOLEAN NOT NULL DEFAULT false`
- `image_rate_independent BOOLEAN NOT NULL DEFAULT false`
- `image_rate_multiplier DECIMAL(10,4) NOT NULL DEFAULT 1.0`
字段语义:
- `allow_image_generation`:是否支持当前分组生图。
- `image_rate_independent=false`:图片计费共享当前普通计费链路里的有效倍率,即当前 `userGroupRateResolver.Resolve(ctx, user.ID, groupID, group.RateMultiplier)` 得到的倍率;这保持现有行为。
- `image_rate_independent=true`:图片计费使用 `group.image_rate_multiplier`;普通编码的 `rate_multiplier` 和用户专属普通倍率不参与图片扣费。
- `image_price_1k/2k/4k`:继续表示图片基础单价,由选中的图片倍率模式继续相乘。
新建分组默认 `allow_image_generation=false`,避免新普通编码分组意外获得生图能力。为避免升级后立即打断已有图片业务,迁移对现有 `openai``gemini``antigravity` 分组回填 `allow_image_generation=true``anthropic` 分组保持 `false`。该回填只是兼容现状;上线后管理员必须按业务策略关闭不允许生图的普通编码分组。
迁移不改写已有 `image_price_1k/2k/4k`,并将所有现有分组设为 `image_rate_independent=false``image_rate_multiplier=1`。这样现有最终扣费公式保持不变:
```text
历史/默认模式图片最终扣费 = image_price_* * image_count * 当前有效分组倍率
```
普通编码分组 `rate_multiplier=0.15` 且希望图片 1K 最终扣费 `0.2/张` 时,管理员不再需要填写 `0.2/0.15`,而是设置:
```text
image_rate_independent = true
image_rate_multiplier = 1
image_price_1k = 0.2
```
如果希望图片也打折,例如图片标价 `0.2/张`、图片折扣 `0.8`,则设置:
```text
image_rate_independent = true
image_rate_multiplier = 0.8
image_price_1k = 0.2
```
### 2. 生图意图统一识别
新增一个服务层 helper输入至少包含 endpoint、请求模型、请求体输出是否为生图意图
```text
isImageGenerationIntent =
endpoint 是 /v1/images/generations 或 /v1/images/edits
OR requested model 以 gpt-image- 开头
OR tools[] 存在 type == image_generation
OR tool_choice 显式指向 image_generation
```
生图意图判断必须在请求体被 Codex 注入、模型改写、渠道映射改写之前执行一次,并在这些改写之后再对最终请求体执行一次。原因是当前代码会在 `backend/internal/service/openai_gateway_service.go` 中注入 `image_generation` tool也会在 `normalizeOpenAIResponsesImageOnlyModel` 中把 `gpt-image-*` 改写为文本模型 + 图片工具;只检查改写前或只检查改写后都可能漏掉场景。
`tool_choice` 判断只把明确指向 `image_generation` 的值视为生图意图;`auto``none``required` 本身不构成生图意图,但如果 `tools[]` 中存在 `image_generation`,仍由 `tools[]` 规则命中。
该判断必须在以下位置使用:
- `/v1/images/*` handler 解析请求后、账号调度前。
- `/v1/responses` 解析 body 后、Codex 自动注入 `image_generation` tool 前。
- `normalizeOpenAIResponsesImageOnlyModel``gpt-image-*` 改写为 Responses 文本模型前。
- OpenAI 高级 scheduler 入口保留现有账号能力检查,同时补齐渠道 restriction 检查,避免启用高级调度时绕过渠道模型限制。
`allow_image_generation=false` 时:
- 显式生图意图返回 HTTP 403错误类型使用现有 `permission_error` 风格。
- Codex CLI 请求不自动注入 `image_generation` tool也不追加图片桥接指令如果请求没有显式生图意图则继续按普通文本请求处理。
### 3. gpt-5.4 / gpt-5.5 生图承载方式
`gpt-5.4` / `gpt-5.5` 生图通过现有 OpenAI Responses API 的 `image_generation` tool 承载,不新增专用 endpoint
```json
{
"model": "gpt-5.4",
"input": "生成一张图片",
"tools": [
{
"type": "image_generation",
"model": "gpt-image-2",
"size": "1024x1024",
"output_format": "png"
}
],
"tool_choice": { "type": "image_generation" }
}
```
`model=gpt-image-*` 发到 `/v1/responses` 时保留现有改写方向:主模型改为 Responses 文本模型,图片模型放入 `image_generation` tool。计费时如果能从工具配置得到 `gpt-image-*`,图片默认价格按该图片模型解析;如果工具未指定图片模型,则使用当前转发结果的 billing model并优先使用分组/渠道配置价格。
### 4. 图片数量归因
新增统一图片输出解析 helper返回去重后的图片数量和可用图片元信息。必须覆盖以下已有或可借鉴的事件形态
- 非流式 Responses JSON`output[]``type == image_generation_call``result` 非空。
- Responses SSE`response.output_item.done``item.type == image_generation_call``item.result` 非空。
- Responses SSE 完成事件:`response.completed.response.output[]` 中图片工具结果。
- Images API 非流式:顶层 `data[]`
- Images API 流式:顶层 `data[]``image_generation.completed``response.output_item.done``response.completed`
去重键按优先级使用 `item.id``call_id``result` 内容 hash。只统计最终图片不统计 `partial_image`
`openaiStreamingResult` 增加 `imageCount``imageSize``imageBillingModel``handleStreamingResponse``handleStreamingResponsePassthrough``handleNonStreamingResponse``handleNonStreamingResponsePassthrough` 都必须把解析结果带回 `OpenAIForwardResult`。当 `ImageCount > 0` 时,即使上游 usage 为 0也必须写 usage log 并进入图片计费。
### 5. 图片价格公式
图片计费先确定单价,再确定倍率:
```text
unit_price = 渠道 image 模式价格 或 分组 image_price_* 或 默认图片价格
image_multiplier =
如果 group.image_rate_independent == true: group.image_rate_multiplier
否则: 当前有效分组倍率
total_cost = unit_price * image_count
actual_cost = total_cost * image_multiplier
```
“当前有效分组倍率”必须沿用当前代码的倍率解析方式:默认配置倍率 → 分组 `rate_multiplier` → 用户专属分组倍率覆盖。这样 `image_rate_independent=false` 时完全保留当前行为。
`billing_mode=image` 的渠道价格是图片单价来源之一,仍优先于分组图片价格。图片渠道价格也必须按 `ImageCount` 计数,并使用同一套 `image_multiplier` 选择逻辑。
`billing_mode=per_request` 的非图片请求保持当前普通按次语义,继续使用普通 token 倍率;只有已经识别为图片请求且 `ImageCount > 0` 的路径使用图片计费逻辑。
`usage_logs.rate_multiplier` 继续表示“本次扣费实际使用的倍率”。因此:
- token 日志记录普通 token 有效倍率。
- image 日志在共享模式记录普通有效倍率。
- image 日志在独立模式记录 `image_rate_multiplier`
专用 `/v1/images/*` 仍按图片请求语义计费:当 `ImageCount > 0` 时,图片价格决定费用,伴随的上游 token usage 只记录不额外计 token 费用。这保持当前 Images API 的行为。
通用 `/v1/responses + image_generation` 的混合文本+图片输出存在一个明确取舍:如果继续沿用“`ImageCount > 0` 时只按图片计费”的当前计费分支,用户可以在一次图片请求中夹带大量文本输出而只付图片费用;如果改成“图片费用 + 非图片 token 费用”,会改变当前 `billing_mode=image` 的单一计费语义,并可能让渠道图片单价不再是全包价格。本变更为最大兼容性不引入混合计费模式,但必须在 usage log 中完整记录 token 与 image_count便于后续按数据决定是否新增 `image_plus_token` 计费模式。
### 6. 尺寸档位与参数透传
OpenAI 图片请求的 `size` 参数必须透传给上游;本地只做计费分档,不做 OpenAI 尺寸合法性校验。无论尺寸是否满足官方约束,本地都不能因为未知尺寸或 provider-invalid 尺寸返回 400如果上游不接受该尺寸由上游响应错误。
官方 `gpt-image-2` 文档给出的常用尺寸与约束是本地计费分档的依据:
- 常用尺寸:`1024x1024``1536x1024``1024x1536``2048x2048``2048x1152``3840x2160``2160x3840``auto`
- 自定义尺寸:官方支持满足约束的任意 `size`包括边长、16 像素倍数、长短边比例、总像素范围等约束。
- `2560x1440` 是 2K/QHD 参考边界;超过 `2560x1440` 总像素的输出进入更高档位风险区。
OpenAI 图片尺寸分层必须按以下规则:
```text
empty, auto => 2K
1024x1024 => 1K
1536x1024, 1024x1536 => 2K
1792x1024, 1024x1792 => 2K
2048x2048, 2048x1152, 1152x2048 => 2K
3840x2160, 2160x3840 => 4K
未知且无法解析为正整数 WIDTHxHEIGHT => 2K
未知且 WIDTH * HEIGHT <= 2560*1440 => 2K
未知且 WIDTH * HEIGHT > 2560*1440 => 4K
```
这个规则只决定 `ImageSize` 和扣费档位,不修改请求体,不删除未知参数,不把未知尺寸改写成预设尺寸。
## Risks / Trade-offs
- 历史普通编码分组迁移后仍默认允许生图 → 通过管理员可见开关、上线核对清单和新建分组默认关闭来控制;代码无法可靠判断“普通编码分组”和“图片分组”的业务意图。
- 默认共享现有有效倍率仍保留“图片最终价不直观”的问题 → 这是兼容性选择;需要直观设置图片最终价的分组必须打开 `image_rate_independent`
- 独立图片倍率不会读取用户专属普通倍率 → 这是目标行为;如需要用户级图片独立倍率,应作为后续独立需求实现。
- 通用 Responses 图片工具可能同时输出文本和图片 → 本变更默认仍按图片请求语义计费并完整记录 token若业务要求文本也收费应新增独立的混合计费模式不能混入本次兼容性变更。
- 本地不再拦截未知或 provider-invalid OpenAI 尺寸 → 非法尺寸会消耗一次上游请求失败成本和用户体验往返,但这是为了保证参数透传、兼容官方新增尺寸和第三方兼容提供商;计费只在成功产出最终图片后发生。
- Responses 流式解析需要在客户端断开后继续 drain 上游以完成计费 → 沿用当前流式处理“客户端断开后继续读取上游用于计费”的模式,并只新增轻量 JSON 路径提取。
- 预扣费不在本变更中实现 → 继续使用现有成功后扣费模型,避免失败请求退款、流式中断退款和图片数量未确定时预估错误。
## Migration Plan
1. 新增数据库迁移,添加 `groups.allow_image_generation``groups.image_rate_independent``groups.image_rate_multiplier`
2. 回填现有分组:`openai``gemini``antigravity``allow_image_generation=true``anthropic=false`;所有现有分组 `image_rate_independent=false``image_rate_multiplier=1`
3. 不改写现有 `image_price_1k/2k/4k`,保持默认共享倍率模式下的历史扣费结果。
4. 更新 Ent schema 与生成代码,更新后端 service/handler DTO 和前端类型。
5. 先接入权限判断,确保未开启生图的分组不会到达上游。
6. 再接入图片数量解析和图片计费倍率选择,确保开启生图的分组按图片数量收费。
7. 最后更新前端管理界面、i18n、文档和测试。
8. 回滚时只能通过新迁移回滚字段行为;不能修改已应用迁移文件。
## Open Questions
无。当前方案不依赖未确认的上游新尺寸、新模型或新 endpoint。

View File

@@ -1,29 +0,0 @@
## Why
当前代码把“能否生图”和“如何按图片收费”混在模型、分组倍率、渠道定价与 Responses 工具调用里,导致 OpenAI 普通编码分组在允许 `gpt-5.4` / `gpt-5.5` 时也能通过 `image_generation` tool 产图,并且通用 `/v1/responses` 产图不会稳定写入 `ImageCount`。需要把生图能力、图片倍率模式、图片产出数量归因拆成独立能力,保证普通编码分组可按业务开关生图,开启后既能沿用现有倍率行为,也能按需切换到图片独立倍率。
## What Changes
- 新增分组级生图能力开关,明确控制 `/v1/images/*``gpt-image-*`、显式 `image_generation` tool、Codex 自动注入图片工具等所有生图入口。
- 新增分组级图片倍率模式开关,默认继续共享现有分组有效倍率;打开独立模式后使用图片独立倍率输入框。
- 保留现有 `image_price_1k/2k/4k` 图片价格配置;图片最终扣费由“图片价格 × 当前倍率模式选出的倍率 × 图片数量”决定。
- 统一统计 OpenAI Responses 图片工具产物数量,使 `gpt-5.4` / `gpt-5.5` 通过 `image_generation` tool 产图时进入图片计费,而不是退化成普通 token 计费或无 usage 时不计费。
- 修正专用 Images API 与渠道图片计费场景,按实际图片数量和明确尺寸档位计费,避免固定 `RequestCount=1` 或未知尺寸静默落到 `2K`
- 更新后台分组配置、前端类型、使用说明和测试,覆盖普通编码分组关闭生图、普通编码分组开启生图、独立图片分组承载、生图流式/非流式等场景。
## Capabilities
### New Capabilities
- `image-generation-access-control`: 定义分组级生图能力开关、所有生图意图识别规则、拒绝行为与 Codex 自动注入规则。
- `image-generation-billing-accounting`: 定义图片倍率模式、图片数量归因、尺寸档位、渠道图片价格和用量日志要求。
### Modified Capabilities
- 无。
## Impact
- Backend schema/API: `backend/ent/schema/group.go`、Ent 生成代码、数据库迁移、管理员分组 create/update/list DTO、分组缓存/序列化。
- Backend request gates: `backend/internal/handler/openai_images.go``backend/internal/service/openai_gateway_service.go``backend/internal/service/openai_codex_transform.go`、OpenAI account scheduler 相关模型/图片能力调度入口。
- Backend billing: `backend/internal/service/billing_service.go``backend/internal/service/openai_gateway_service.go``backend/internal/service/gateway_service.go`、usage log 与 account stats 成本计算路径。
- Frontend admin: `frontend/src/types/index.ts``frontend/src/views/admin/GroupsView.vue`、相关 i18n 文案与图片计费展示。
- Tests: OpenAI Images API、OpenAI Responses stream/non-stream/passthrough、分组开关、图片倍率模式、渠道图片计数、尺寸档位与 usage log 断言。

View File

@@ -1,118 +0,0 @@
## ADDED Requirements
### Requirement: Group image generation capability
The system SHALL store a group-level `allow_image_generation` capability flag and SHALL expose it through admin group create, update, list, and detail APIs.
#### Scenario: New group defaults to image generation disabled
- **WHEN** an admin creates a group without providing `allow_image_generation`
- **THEN** the persisted group has `allow_image_generation=false`
#### Scenario: Existing image-capable platform groups are backfilled
- **WHEN** the migration is applied to existing groups
- **THEN** existing `openai`, `gemini`, and `antigravity` groups have `allow_image_generation=true`
- **AND** existing `anthropic` groups have `allow_image_generation=false`
#### Scenario: Admin enables image generation on an ordinary coding group
- **WHEN** an admin updates an `openai` group with `allow_image_generation=true`
- **THEN** the group can use image generation paths subject to the billing requirements
#### Scenario: Admin disables image generation on an ordinary coding group
- **WHEN** an admin updates an `openai` group with `allow_image_generation=false`
- **THEN** the group can still use non-image text model requests
- **AND** image generation intents are denied before upstream dispatch
### Requirement: Image generation intent detection
The system SHALL classify a request as an image generation intent before upstream account scheduling when the endpoint or request body can produce generated images.
#### Scenario: Images endpoint is an image generation intent
- **WHEN** a request targets `/v1/images/generations`, `/v1/images/edits`, `/images/generations`, or `/images/edits`
- **THEN** the request is classified as an image generation intent
#### Scenario: Responses request with image-only model is an image generation intent
- **WHEN** a `/v1/responses` request has a requested model whose normalized name starts with `gpt-image-`
- **THEN** the request is classified as an image generation intent before any model rewrite
#### Scenario: Responses request with image_generation tool is an image generation intent
- **WHEN** a `/v1/responses` request contains any `tools[]` entry with `type == "image_generation"`
- **THEN** the request is classified as an image generation intent
#### Scenario: Responses request with image_generation tool_choice is an image generation intent
- **WHEN** a `/v1/responses` request contains `tool_choice` that explicitly selects `image_generation`
- **THEN** the request is classified as an image generation intent even if `tools[]` is malformed or absent
#### Scenario: Generic tool_choice required is not sufficient by itself
- **WHEN** a `/v1/responses` request contains `tool_choice="required"`
- **AND** the request does not contain an `image_generation` tool
- **THEN** the request is not classified as an image generation intent because of `tool_choice` alone
#### Scenario: Text-only gpt-5.4 request is not an image generation intent
- **WHEN** a `/v1/responses` request uses `model="gpt-5.4"` or `model="gpt-5.5"` without `image_generation` tool and without image `tool_choice`
- **THEN** the request is not classified as an image generation intent
#### Scenario: Intent is checked before and after service-side mutation
- **WHEN** the service mutates a `/v1/responses` request by injecting `image_generation` or rewriting `gpt-image-*` to a Responses text model plus image tool
- **THEN** the final mutated request is checked against the same image generation intent rules before upstream dispatch
### Requirement: Disabled groups reject explicit image generation
The system SHALL reject explicit image generation intents for groups with `allow_image_generation=false` before selecting or calling an upstream account.
#### Scenario: Disabled group rejects Images API
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/images/generations`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** no upstream account is selected
- **AND** no usage log is written
#### Scenario: Disabled group rejects Responses image tool
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `tools:[{"type":"image_generation"}]`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** no upstream account is selected
- **AND** no usage log is written
#### Scenario: Disabled group rejects Responses image-only model rewrite
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `model` starting with `gpt-image-`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** the request is not rewritten to a text Responses model
#### Scenario: Disabled group permits normal coding request
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `model="gpt-5.4"` and no image generation intent
- **THEN** the request proceeds through the normal text forwarding path
### Requirement: Codex image tool injection respects group capability
The system SHALL only inject the OpenAI Responses `image_generation` tool and bridge instructions for Codex clients when the request group has `allow_image_generation=true`.
#### Scenario: Codex request in enabled group receives image tool
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=true`
- **AND** the request has no `image_generation` tool
- **THEN** the system injects the existing `image_generation` tool payload
- **AND** the system appends the existing Codex image bridge instructions
#### Scenario: Codex request in disabled group does not receive image tool
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=false`
- **AND** the request has no explicit image generation intent
- **THEN** the system does not inject `image_generation`
- **AND** the system does not append image bridge instructions
- **AND** the request proceeds as a text request
#### Scenario: Codex explicit image request in disabled group is denied
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=false`
- **AND** the request explicitly contains `image_generation`
- **THEN** the system returns HTTP 403 with error type `permission_error`
### Requirement: Channel model restrictions remain enforced
The system SHALL keep existing channel model restriction behavior for image and non-image OpenAI requests, including when the advanced OpenAI account scheduler is enabled.
#### Scenario: Advanced scheduler blocks restricted requested model
- **WHEN** a channel has `restrict_models=true`
- **AND** the requested model is not allowed by channel pricing or mapping rules
- **AND** the OpenAI advanced scheduler path is used
- **THEN** the request is rejected before upstream account selection succeeds
#### Scenario: Image generation flag does not bypass channel restrictions
- **WHEN** a group has `allow_image_generation=true`
- **AND** the channel restriction rejects the requested or billing model
- **THEN** the image generation request is rejected
- **AND** no upstream image request is sent

View File

@@ -1,225 +0,0 @@
## ADDED Requirements
### Requirement: Image multiplier mode
The system SHALL calculate image generation cost with group image prices and a selectable image multiplier mode. By default image billing SHALL share the existing effective group multiplier; when `image_rate_independent=true`, image billing SHALL use `image_rate_multiplier`.
#### Scenario: Default image billing shares current effective group multiplier
- **WHEN** a group has `rate_multiplier=0.15`
- **AND** `image_rate_independent=false`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.03`
- **AND** the calculation matches current default behavior
#### Scenario: User-specific token multiplier still applies in shared mode
- **WHEN** a user has a user-group token multiplier override of `0.2`
- **AND** the group has `image_rate_independent=false`
- **AND** `image_price_1k=0.5`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.1`
- **AND** the applied image multiplier is the same effective multiplier used by token billing
#### Scenario: Independent image multiplier allows direct final price
- **WHEN** a group has `rate_multiplier=0.15`
- **AND** `image_rate_independent=true`
- **AND** `image_rate_multiplier=1`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.2`
- **AND** ordinary `rate_multiplier=0.15` is not applied to the image cost
#### Scenario: Independent image multiplier supports image discounts
- **WHEN** a group has `image_rate_independent=true`
- **AND** `image_rate_multiplier=0.5`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces two `1K` images
- **THEN** `total_cost` is `0.4`
- **AND** `actual_cost` is `0.2`
#### Scenario: Migration preserves existing image price behavior
- **WHEN** an existing group has `rate_multiplier=0.15` and `image_price_1k=1.3333333333`
- **AND** the migration is applied
- **THEN** the stored `image_price_1k` remains `1.3333333333`
- **AND** the stored `image_rate_independent` is `false`
- **AND** the stored `image_rate_multiplier` is `1`
- **AND** default-mode image billing still produces the historical final price within decimal precision
#### Scenario: Omitted update fields preserve existing multiplier mode
- **WHEN** an admin updates a group without sending `image_rate_independent`
- **AND** without sending `image_rate_multiplier`
- **THEN** the stored image multiplier mode and image multiplier value remain unchanged
#### Scenario: Image multiplier can be zero only by explicit independent mode configuration
- **WHEN** a group has `image_rate_independent=true`
- **AND** `image_rate_multiplier=0`
- **AND** a successful image request produces one image
- **THEN** the image request is free
- **AND** this free-image behavior does not occur unless the group explicitly enables independent image multiplier mode with zero multiplier
### Requirement: Responses image output accounting
The system SHALL count generated image outputs from OpenAI Responses stream, non-stream, and passthrough paths and SHALL return the count in `OpenAIForwardResult.ImageCount`.
#### Scenario: Non-stream Responses image tool output is counted
- **WHEN** a non-stream `/v1/responses` upstream response contains `output[]` item with `type == "image_generation_call"` and non-empty `result`
- **THEN** `OpenAIForwardResult.ImageCount` equals the number of unique final image outputs
- **AND** `OpenAIForwardResult.ImageSize` is the normalized image size tier
#### Scenario: Stream Responses output item is counted
- **WHEN** a stream `/v1/responses` upstream SSE event has `type == "response.output_item.done"`
- **AND** the event item has `type == "image_generation_call"` and non-empty `result`
- **THEN** the streaming result increments the unique final image output count
#### Scenario: Stream Responses completed output is counted
- **WHEN** a stream `/v1/responses` upstream SSE event has `type == "response.completed"`
- **AND** `response.output[]` contains final image generation outputs
- **THEN** the streaming result counts those images without double-counting images already seen in `response.output_item.done`
#### Scenario: Partial image events are not billed as completed images
- **WHEN** a stream response contains `partial_image` events
- **THEN** those partial events do not increment `ImageCount`
- **AND** only final image generation outputs increment `ImageCount`
#### Scenario: gpt-5.4 image tool request is billed as image
- **WHEN** a `/v1/responses` request uses `model="gpt-5.4"` or `model="gpt-5.5"`
- **AND** the request includes an `image_generation` tool
- **AND** the upstream response contains one final image output
- **THEN** the usage log has `image_count=1`
- **AND** the usage log has `billing_mode="image"`
- **AND** image pricing, not token pricing, determines `actual_cost`
#### Scenario: Image output with zero usage is still billed
- **WHEN** an upstream Responses result contains final image output
- **AND** the upstream result has zero or missing token usage
- **THEN** the system writes a usage log
- **AND** the system bills using image pricing
#### Scenario: Responses image request records accompanying token usage
- **WHEN** a `/v1/responses` image tool request returns final images and token usage
- **THEN** the usage log records input tokens, output tokens, image output tokens, and image count
- **AND** the applied billing mode remains `image`
#### Scenario: Responses image request does not introduce hybrid billing by default
- **WHEN** a `/v1/responses` image tool request returns final images and text tokens
- **THEN** the request is billed by image pricing under this change
- **AND** non-image token charges are not added unless a future explicit hybrid billing mode is implemented
### Requirement: OpenAI Images API output accounting
The system SHALL count generated images from dedicated OpenAI Images API stream and non-stream paths and SHALL set `ImageCount` for successful image responses.
#### Scenario: Images non-stream data array is counted
- **WHEN** `/v1/images/generations` returns a non-stream JSON response with top-level `data[]`
- **THEN** `ImageCount` equals the length of `data[]`
#### Scenario: Images stream data array is counted
- **WHEN** `/v1/images/generations` stream response emits SSE data containing top-level `data[]`
- **THEN** `ImageCount` equals the maximum final data array count observed for the request
#### Scenario: Images stream completed event is counted
- **WHEN** `/v1/images/generations` stream response emits `image_generation.completed` with a final image payload
- **THEN** the stream result counts one final image output
#### Scenario: Images stream Responses-form event is counted
- **WHEN** an Images API upstream path emits Responses-form `response.output_item.done` or `response.completed` events with final image outputs
- **THEN** the stream result counts final image outputs using the same de-duplication rules as Responses
### Requirement: Channel image billing uses actual image count
The system SHALL use actual generated image count for channel `billing_mode=image` pricing and SHALL NOT bill multi-image requests as a single request.
#### Scenario: OpenAI channel image billing counts multiple images
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** an OpenAI image request produces three images
- **THEN** `total_cost` is `0.75` before the selected image multiplier is applied
- **AND** `RequestCount` passed into unified pricing is `3`
#### Scenario: Gateway channel image billing counts multiple images
- **WHEN** a non-OpenAI gateway image path produces two images
- **AND** channel image pricing resolves for the billing model
- **THEN** `RequestCount` passed into unified pricing is `2`
#### Scenario: Channel image pricing uses shared multiplier by default
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** the group has ordinary effective multiplier `0.15`
- **AND** the group has `image_rate_independent=false`
- **AND** the image request produces one image
- **THEN** `actual_cost` is `0.0375`
#### Scenario: Channel image pricing uses independent image multiplier when enabled
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** the group has ordinary effective multiplier `0.15`
- **AND** the group has `image_rate_independent=true`
- **AND** the group has `image_rate_multiplier=1`
- **AND** the image request produces one image
- **THEN** `actual_cost` is `0.25`
- **AND** ordinary effective multiplier `0.15` is not applied
#### Scenario: Account stats image pricing receives image count
- **WHEN** account stats pricing uses `billing_mode=image`
- **AND** the request produces multiple images
- **THEN** account stats cost is calculated with the actual image count
### Requirement: Image size tier normalization
The system SHALL normalize OpenAI image sizes to explicit billing tiers for billing only. The system SHALL NOT reject requests locally because of an unknown or provider-invalid `size`; it SHALL forward the original size parameter upstream and let the official upstream API decide whether the request is valid.
#### Scenario: OpenAI 1024 square maps to 1K
- **WHEN** an OpenAI image request specifies `size="1024x1024"`
- **THEN** `ImageSize` is `1K`
#### Scenario: OpenAI landscape and portrait large sizes map to 2K
- **WHEN** an OpenAI image request specifies `1536x1024`, `1024x1536`, `1792x1024`, `1024x1792`, `2048x2048`, `2048x1152`, or `1152x2048`
- **THEN** `ImageSize` is `2K`
#### Scenario: OpenAI gpt-image-2 4K presets map to 4K
- **WHEN** an OpenAI `gpt-image-2` image request specifies `3840x2160` or `2160x3840`
- **THEN** `ImageSize` is `4K`
#### Scenario: OpenAI auto size maps to 2K
- **WHEN** an OpenAI image request omits size or specifies `size="auto"`
- **THEN** `ImageSize` is `2K`
#### Scenario: Custom OpenAI size is forwarded without local validation
- **WHEN** an OpenAI image request specifies a custom explicit `WIDTHxHEIGHT` size
- **THEN** the system forwards the request upstream
- **AND** `ImageSize` is normalized to `2K` or `4K` for billing
#### Scenario: Responses image tool without model uses default image billing model
- **WHEN** a `/v1/responses` request uses an `image_generation` tool without `tool.model`
- **THEN** image size validation and image billing use `gpt-image-2` as the image billing model
#### Scenario: Invalid OpenAI size constraints are delegated upstream
- **WHEN** an OpenAI image request specifies an explicit size that fails OpenAI size constraints
- **THEN** the system forwards the request upstream
- **AND** any invalid-size error comes from the upstream provider response
#### Scenario: Custom OpenAI size tier mapping
- **WHEN** a custom size cannot be parsed as positive `WIDTHxHEIGHT`
- **THEN** `ImageSize` is `2K`
- **WHEN** a custom size parses as positive `WIDTHxHEIGHT`
- **AND** `WIDTH * HEIGHT` is no more than `2560x1440`
- **THEN** `ImageSize` is `2K`
- **WHEN** a custom size parses as positive `WIDTHxHEIGHT`
- **AND** `WIDTH * HEIGHT` exceeds `2560x1440`
- **THEN** `ImageSize` is `4K`
### Requirement: Image usage log semantics
The system SHALL write usage logs for successful image generation with image billing metadata that matches the applied image pricing path.
#### Scenario: Image usage log records image billing mode
- **WHEN** a successful request has `ImageCount > 0`
- **THEN** the usage log has `billing_mode="image"`
- **AND** the usage log records `image_count`
- **AND** the usage log records `image_size` when a normalized size tier is available
#### Scenario: Shared mode image usage log records shared multiplier
- **WHEN** a successful image request is billed with `image_rate_independent=false`
- **AND** the effective ordinary multiplier is `0.15`
- **THEN** `usage_logs.rate_multiplier` is `0.15`
#### Scenario: Independent mode image usage log records image multiplier
- **WHEN** a successful image request is billed with `image_rate_independent=true`
- **AND** `image_rate_multiplier=0.5`
- **THEN** `usage_logs.rate_multiplier` is `0.5`
#### Scenario: Token request usage log is unchanged
- **WHEN** a successful non-image token request is billed
- **THEN** `usage_logs.rate_multiplier` continues to record the ordinary token multiplier
- **AND** `image_count` is `0`

View File

@@ -1,72 +0,0 @@
## 1. Data Model And Migration
- [x] 1.1 Add `allow_image_generation`, `image_rate_independent`, and `image_rate_multiplier` to `backend/ent/schema/group.go`.
- [x] 1.2 Create a new idempotent SQL migration after `133_affiliate_rebate_freeze.sql` for the three group columns.
- [x] 1.3 Backfill existing `openai`, `gemini`, and `antigravity` groups to `allow_image_generation=true` and `anthropic` groups to `false`.
- [x] 1.4 Backfill all existing groups to `image_rate_independent=false` and `image_rate_multiplier=1` without changing existing `image_price_1k/2k/4k`.
- [x] 1.5 Regenerate or update Ent generated group fields, predicates, create/update setters, and query projections.
- [x] 1.6 Add the new fields to backend group domain/service structs, admin create/update inputs, admin responses, and group serialization.
## 2. Admin API And Frontend
- [x] 2.1 Add `allow_image_generation`, `image_rate_independent`, and `image_rate_multiplier` to `CreateGroupRequest` and `UpdateGroupRequest`.
- [x] 2.2 Validate `image_rate_multiplier >= 0` and keep negative image prices using the existing clear-price behavior only for `image_price_*`.
- [x] 2.3 Add the new fields to `frontend/src/types/index.ts` group, create, and update interfaces.
- [x] 2.4 Ensure omitted update fields do not overwrite existing image generation and multiplier mode settings.
- [x] 2.5 Update `frontend/src/views/admin/GroupsView.vue` create/edit forms with a 生图开关, 生图倍率是否独立开关, and conditional image multiplier input.
- [x] 2.6 Add a live final-price preview for `image_price_1k/2k/4k` under shared and independent multiplier modes.
- [x] 2.7 Update group form help text to state that default image billing shares the existing group effective multiplier and independent mode uses the image multiplier input.
- [x] 2.8 Update i18n strings for the new controls and image multiplier mode explanation.
## 3. Image Generation Access Control
- [x] 3.1 Implement a shared helper that detects image generation intent from endpoint, requested model, `tools[]`, and `tool_choice`.
- [x] 3.2 Gate `/v1/images/generations` and `/v1/images/edits` in `backend/internal/handler/openai_images.go` after request parsing and before billing eligibility/account scheduling.
- [x] 3.3 Gate `/v1/responses` explicit `image_generation` tool requests in `backend/internal/service/openai_gateway_service.go` before upstream account scheduling.
- [x] 3.4 Prevent `normalizeOpenAIResponsesImageOnlyModel` from rewriting `gpt-image-*` Responses requests when the group does not allow image generation.
- [x] 3.5 Skip Codex `image_generation` auto-injection and image bridge instructions when the group does not allow image generation.
- [x] 3.6 Re-run image intent detection after service-side request mutation and before upstream dispatch.
- [x] 3.7 Ensure OpenAI advanced scheduler paths apply the same channel `RestrictModels` checks as the load-aware path.
## 4. Responses Image Output Accounting
- [x] 4.1 Add shared parsers for final `image_generation_call.result` outputs in non-stream JSON and SSE payloads.
- [x] 4.2 Extend `openaiStreamingResult` with image count, image size tier, and image billing model fields.
- [x] 4.3 Update `handleStreamingResponse` to count final image outputs while preserving existing stream forwarding and usage parsing.
- [x] 4.4 Update `handleStreamingResponsePassthrough` with the same image output counting.
- [x] 4.5 Update `handleNonStreamingResponse` to count final image outputs from `output[]`.
- [x] 4.6 Update `handleNonStreamingResponsePassthrough` with the same non-stream image output counting.
- [x] 4.7 Populate `OpenAIForwardResult.ImageCount`, `ImageSize`, and image billing model for `gpt-5.4` / `gpt-5.5 + image_generation` requests.
## 5. Images API Accounting And Size Tiers
- [x] 5.1 Extend OpenAI Images API-key stream counting to handle `image_generation.completed`, `response.output_item.done`, and `response.completed`.
- [x] 5.2 Reuse the same final-image de-duplication rules across Images API and Responses API paths.
- [x] 5.3 Keep unknown explicit OpenAI image sizes pass-through and delegate invalid-size errors to upstream.
- [x] 5.4 Map documented OpenAI image sizes to `1K`/`2K`/`4K` billing tiers without rewriting request parameters.
- [x] 5.5 Classify custom OpenAI `WIDTHxHEIGHT` sizes by `2560x1440` total-pixel boundary, falling back to `2K` when unparseable.
## 6. Billing And Usage Logs
- [x] 6.1 Add an image multiplier resolver: shared mode uses the current effective group multiplier, independent mode uses `apiKey.Group.ImageRateMultiplier`.
- [x] 6.2 Update `CalculateImageCost` or its caller contract so image costs use the resolved image multiplier.
- [x] 6.3 Set image usage log `RateMultiplier` to the applied image multiplier; keep token logs unchanged.
- [x] 6.4 Change OpenAI channel image billing `RequestCount` from `1` to `result.ImageCount`.
- [x] 6.5 Change non-OpenAI gateway channel image billing `RequestCount` from `1` to `result.ImageCount`.
- [x] 6.6 Pass actual image count into account stats pricing for `billing_mode=image`.
- [x] 6.7 Ensure `ImageCount > 0` writes a usage log and bills even when upstream token usage is zero.
- [x] 6.8 Record accompanying token usage for Responses image tool requests while keeping default billing mode as `image`.
## 7. Tests And Documentation
- [x] 7.1 Add backend tests for disabled group rejecting `/v1/images/*`, `gpt-image-*` Responses, explicit `image_generation`, and image `tool_choice`.
- [x] 7.2 Add backend tests proving disabled Codex groups do not receive injected image tools while enabled Codex groups still do.
- [x] 7.3 Add backend tests proving omitted group update fields preserve existing image generation and multiplier mode settings.
- [x] 7.4 Add Responses stream and non-stream tests for `gpt-5.4` / `gpt-5.5 + image_generation` image counting and image billing.
- [x] 7.5 Add Images API stream tests for `image_generation.completed`, `response.output_item.done`, and `response.completed` counting.
- [x] 7.6 Add billing tests for shared mode `rate_multiplier=0.15`, `image_price_1k=0.2`, final `actual_cost=0.03`.
- [x] 7.7 Add billing tests for independent mode `rate_multiplier=0.15`, `image_rate_multiplier=1`, `image_price_1k=0.2`, final `actual_cost=0.2`.
- [x] 7.8 Add channel image billing tests proving multi-image requests use `RequestCount=ImageCount` in both shared and independent multiplier modes.
- [x] 7.9 Add size-tier tests for known OpenAI sizes and unknown explicit size pass-through.
- [x] 7.10 Add Responses image tool tests proving token usage is recorded but default billing remains image-mode only.
- [x] 7.11 Update `2ue/image-billing-risk-analysis.md` or add a linked follow-up note that points to this OpenSpec change as the normalized solution.

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-03

View File

@@ -1,70 +0,0 @@
## Overview
本次只实现“图片独立并发开关”,不实现外部图片网关的运行时代码。目标是在最大程度不改变现有行为的前提下,为图片流式长连接提供服务级资源保护。
## Current Constraints
- 当前 Redis 并发槽位只有用户和账号维度,键语义是 `concurrency:user:*``concurrency:account:*`
- 图片接口和普通 Responses 在同一个 Go 服务内运行共享进程、HTTP 上游连接池和账号调度。
- Codex OAuth 路径会自动注入 `image_generation` tool这个注入表示“模型具备工具能力”不等价于当前请求一定会生图。
- `/v1/responses` 在 handler 入口只能可靠识别显式图片意图image 模型、请求体已有 image tool、或 tool_choice 明确选择 image_generation。
- 图片实际产物计数与计费仍以 service 层的最终输出解析为准。
## Decisions
### 1. 默认关闭,保持兼容
新增配置:
- `gateway.image_concurrency.enabled`,默认 `false`
- `gateway.image_concurrency.max_concurrent_requests`,默认 `0`,表示不限制。
- `gateway.image_concurrency.overflow_mode`,默认 `reject`,可选 `reject` / `wait`
- `gateway.image_concurrency.wait_timeout_seconds`,默认 `30`,仅 `overflow_mode=wait` 生效。
- `gateway.image_concurrency.max_waiting_requests`,默认 `100`,仅 `overflow_mode=wait` 生效,限制当前进程内图片等待队列。
只有当 `enabled=true``max_concurrent_requests>0` 时才启用图片独立并发限制。默认配置不改变任何现有流量行为。
### 2. 进程级信号量作为第一阶段隔离
本次使用进程内有界信号量做服务级图片并发限制。原因:
- 不扩展现有 Redis `ConcurrencyCache` 接口,避免影响用户/账号并发的既有语义。
- 不新增迁移,不改变分组已有字段。
- 单实例部署可立即保护进程资源。
- 多实例部署时该限制按实例生效;文档必须明确总图片并发约等于 `实例数 × max_concurrent_requests`
### 3. 限制对象只包含明确图片意图
纳入限制:
- `/v1/images/generations`
- `/v1/images/edits`
- `/v1/responses` 中入口请求已明确包含图片意图image 模型、`tools[].type=image_generation``tool_choice` 明确选择 image_generation。
暂不纳入限制:
- 普通 Codex 请求因为服务端自动注入 image tool 而具备生图能力,但入口请求本身未明确要求生图。
这样避免把普通编码请求错误算作图片并发。后续若要对“模型运行中动态调用 image tool”做更细粒度隔离需要在工具调用实际发生时获得可阻塞的事件目前当前代码没有这种入口级阻塞点。
### 4. 限流行为
- `overflow_mode=reject` 时,未开始流式响应直接返回 HTTP `429`,错误类型 `rate_limit_error`
- `overflow_mode=wait` 时,请求在当前进程内等待图片并发槽位,超过 `wait_timeout_seconds` 或超过 `max_waiting_requests` 后返回 HTTP `429`
- 已开始流式响应时,使用现有 `handleStreamingAwareError` 写 SSE 错误事件。
- 图片并发限制命中或等待超时不触发账号 failover不记录为上游账号失败。
- `gateway.image_stream_data_interval_timeout` 是上游图片流数据空闲超时,不用于图片排队等待。
### 5. 与外部图片网关的关系
本次不实现外部图片网关代码。外部网关方案沉淀到 `2ue` 文档:
- 推荐由 Caddy/Nginx/API Gateway 按 `/v1/images/*` 分流。
- `/v1/responses` 的图片 tool 请求不能仅靠 path 分流,必须在前置层读取 body 或保留主服务兜底。
- 即使未来拆出图片网关,主网关仍保留图片 intent 检测、开关和计费兜底,避免直连或漏判绕过。
## Risks And Mitigations
- 风险:进程级限制在多实例部署下不是全局严格限制。缓解:文档明确容量计算,后续可基于 Redis 扩展为集群级图片并发。
- 风险Codex 自动注入 image tool 后,普通编码请求未被图片限流。缓解:这是有意选择,避免误伤普通请求;实际输出图片仍按图片计费。
- 风险图片请求在账号槽位前被拒绝可能改变排队体验。缓解仅当独立开关启用时生效默认关闭429 明确提示图片并发达到上限。

View File

@@ -1,28 +0,0 @@
## Why
图片生成流式请求会比普通文本流式请求占用更长的连接、goroutine、HTTP 上游连接和账号/用户槽位。当前图片能力已经具备独立计费与更长流式超时,但仍缺少默认关闭的图片专属并发隔离开关,图片高并发时仍可能挤压普通文本流式接口。
## What Changes
- 新增服务级图片独立并发开关,默认关闭,不改变现有已部署分组和普通文本请求行为。
- 新增图片全局并发上限配置;开启后仅限制已明确是图片生成意图的请求。
- 新增图片并发满载后的溢出策略配置:默认立即拒绝,也可配置等待槽位和等待超时。
- 将图片并发限制覆盖 `/v1/images/generations``/v1/images/edits``/v1/responses` 显式图片生成请求。
- 保留当前图片生成开关、图片计费、图片流式续读与超时语义。
- 不在本次代码实现外部独立图片网关;只把外部网关拆分方案沉淀到本地文档。
## Capabilities
### New Capabilities
- `image-generation-concurrency-isolation`: 图片生成请求的独立并发开关、并发上限、429 行为和外部网关落地建议。
### Modified Capabilities
- `image-stream-resilience`: 图片流式续读能力在独立并发开启时受到图片专属并发上限保护,但流式续读与计费契约不变。
## Impact
- 影响 `backend/internal/config/config.go` 的 gateway 配置字段、默认值和校验。
- 影响 `backend/internal/handler/openai_images.go``backend/internal/handler/openai_gateway_handler.go` 的图片请求入口限流。
- 影响 `deploy/config.example.yaml` 的示例配置与说明。
- 影响后端测试:配置默认值/校验、图片接口限流、Responses 显式 image tool 限流。
- 新增或更新 `2ue` 本地分析文档,记录外部独立图片网关只作为后续部署方案,不在本次代码落地。

View File

@@ -1,82 +0,0 @@
# image-generation-concurrency-isolation Specification
## ADDED Requirements
### Requirement: Image concurrency isolation is opt-in
The system SHALL keep image concurrency isolation disabled by default.
#### Scenario: default config keeps existing behavior
- **GIVEN** the deployment does not set `gateway.image_concurrency.enabled`
- **WHEN** image generation requests are received
- **THEN** no new image-specific concurrency limit is applied
- **AND** existing user/account concurrency and billing behavior remains unchanged
### Requirement: Dedicated image concurrency limit
The system SHALL provide an opt-in service-level image concurrency limit controlled by gateway configuration.
#### Scenario: explicit image endpoint is limited
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** `gateway.image_concurrency.max_concurrent_requests=1`
- **AND** one image generation request is already active
- **WHEN** another `/v1/images/generations` or `/v1/images/edits` request arrives
- **THEN** the second request is rejected with HTTP `429`
- **AND** the error type is `rate_limit_error`
#### Scenario: explicit Responses image generation request is limited
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** `gateway.image_concurrency.max_concurrent_requests=1`
- **AND** `gateway.image_concurrency.overflow_mode=reject`
- **AND** one image generation request is already active
- **WHEN** a `/v1/responses` request explicitly contains `tools[].type=image_generation`, an image model, or `tool_choice` selecting `image_generation`
- **THEN** the request is rejected with HTTP `429`
- **AND** it is not retried through account failover
#### Scenario: image request waits for a slot
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** `gateway.image_concurrency.max_concurrent_requests=1`
- **AND** `gateway.image_concurrency.overflow_mode=wait`
- **AND** `gateway.image_concurrency.wait_timeout_seconds` is greater than zero
- **AND** one image generation request is already active
- **WHEN** another explicit image generation request arrives
- **AND** the active image generation request releases its slot before the wait timeout
- **THEN** the waiting image generation request acquires the slot and continues
#### Scenario: image wait times out
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** `gateway.image_concurrency.max_concurrent_requests=1`
- **AND** `gateway.image_concurrency.overflow_mode=wait`
- **AND** one image generation request is already active
- **WHEN** another explicit image generation request waits longer than `gateway.image_concurrency.wait_timeout_seconds`
- **THEN** the waiting request is rejected with HTTP `429`
- **AND** the error type is `rate_limit_error`
#### Scenario: image waiting queue is full
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** `gateway.image_concurrency.overflow_mode=wait`
- **AND** `gateway.image_concurrency.max_waiting_requests` is already reached
- **WHEN** another explicit image generation request arrives
- **THEN** the request is rejected with HTTP `429`
- **AND** it does not wait for account scheduling
### Requirement: Text requests are not image-limited
The system SHALL NOT apply the image concurrency limit to requests without explicit image generation intent.
#### Scenario: normal coding request bypasses image limit
- **GIVEN** `gateway.image_concurrency.enabled=true`
- **AND** the image concurrency limit is full
- **WHEN** a `/v1/responses` request uses a text model and does not explicitly contain image generation intent
- **THEN** the image concurrency limiter does not reject it
- **AND** normal user/account concurrency handling continues
### Requirement: External image gateway remains a deployment pattern
The system SHALL document external image gateway routing as a deployment option without adding runtime forwarding code in this change.
#### Scenario: operator reads local design note
- **GIVEN** the repository documentation is available
- **WHEN** an operator evaluates isolating image traffic into a separate service
- **THEN** local `2ue` documentation describes which paths are safe to route by path
- **AND** explains why `/v1/responses` image tool requests require body-aware routing or main-gateway fallback

View File

@@ -1,28 +0,0 @@
## 1. Spec and documentation
- [x] 1.1 Create OpenSpec proposal, design, tasks, and capability spec for image concurrency isolation.
- [x] 1.2 Add a local `2ue` note for the external image gateway deployment pattern and current non-goals.
## 2. Config
- [x] 2.1 Add `gateway.image_concurrency.enabled` and `gateway.image_concurrency.max_concurrent_requests` config fields.
- [x] 2.2 Register defaults that keep existing behavior unchanged.
- [x] 2.3 Validate max concurrent requests as non-negative.
- [x] 2.4 Update `deploy/config.example.yaml` with safe usage notes.
- [x] 2.5 Add image concurrency overflow mode, wait timeout, and max waiting request config.
## 3. Runtime limiter
- [x] 3.1 Implement a process-level image concurrency limiter with resize-on-config-read behavior.
- [x] 3.2 Acquire/release the limiter around `/v1/images/generations` and `/v1/images/edits` before account scheduling.
- [x] 3.3 Acquire/release the limiter around explicit `/v1/responses` image generation intent before account scheduling.
- [x] 3.4 Ensure limiter rejections return `429 rate_limit_error` and do not trigger account failover.
- [x] 3.5 Support `reject` and `wait` overflow modes with bounded wait timeout and waiting queue size.
## 4. Tests and verification
- [x] 4.1 Add config default and validation tests.
- [x] 4.2 Add handler tests for image endpoint limiter rejection.
- [x] 4.3 Add handler tests proving text-only Responses requests are not rejected by the image limiter.
- [x] 4.4 Run focused Go tests for config and OpenAI handler/service paths.
- [x] 4.5 Add limiter tests for wait success, wait timeout, and waiting queue overflow.

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-02

View File

@@ -1,46 +0,0 @@
## Context
现有普通 Responses 流式已经具备较完整的断连续写能力:客户端写失败后可继续 drain 上游,并且具备数据间隔超时和 keepalive。图片流式路径目前仍然采用更直接的读写方式客户端写失败会立即返回上游读取也更容易跟随客户端取消而结束。
本次变更只针对图片流式路径,不改变普通文本流式路径的配置和行为。系统已经存在普通流式的后端超时配置,因此这里不引入页面级超时设置;图片流式只需要独立的后端默认值,让图片生成有更长的容忍窗口。
## Goals / Non-Goals
**Goals:**
- 图片流式在客户端断开后继续读取上游,尽量保留最终图片结果与计费结果。
- 图片流式使用独立于普通流式的超时与 keepalive 默认值。
- 不修改现有普通流式配置项的含义,不要求管理员新增页面配置。
- 维持图片计费与图片结果计数的一致性。
**Non-Goals:**
- 不设计新的前端配置页面。
- 不修改普通文本流式的超时策略。
- 不改变图片计费公式或分组倍率语义。
## Decisions
1. **使用独立的图片流式配置键**
- 选择:在后端配置中增加图片流式专用 `image_stream_data_interval_timeout` / `image_stream_keepalive_interval`
- 原因:图片流式耗时显著更长,复用普通流式默认值会过早触发超时;独立键能避免影响现有文本流式。
- 备选方案:直接复用普通流式配置并在代码里按路径放大倍数。这个方案会让普通流式和图片流式共享语义,后续难以维护。
2. **继续使用上下文 detach而不是依赖客户端上下文**
- 选择:图片流式请求向上游发起时使用 `context.WithoutCancel` 派生的上下文。
- 原因:客户端断开时不应自动取消上游请求,否则无法收集最终图片结果,也无法完成图片计费。
- 备选方案:仍使用 `c.Request.Context()` 并只在写失败后继续 drain。这个方案在客户端取消场景下无法保证上游读取继续进行。
3. **只改图片流式路径,不改普通流式路径**
- 选择:`/v1/images/*``Responses + image_generation` 两条图片流式链路单独处理。
- 原因:风险最小,避免回归普通文本流式和现有超时配置。
- 备选方案:统一重构所有流式处理。这个方案范围更大,验证成本更高,不符合本次“尽量少改现有行为”的目标。
4. **不新增页面配置**
- 选择:图片流式独立超时默认值写入后端配置,沿用当前配置加载方式。
- 原因:用户明确要求和当前设置行为统一,不需要额外页面输入项。
- 备选方案:前端增加图片超时配置项。这个方案会改变现有运维方式,也容易引入误配。
## Risks / Trade-offs
- [Risk] 图片流式继续 drain 上游后,客户端已经断开但服务端仍占用连接与协程资源。→ [Mitigation] 只对图片流式启用更长但仍有限的专用超时,并保持与普通流式同样的 keepalive/超时退出机制。
- [Risk] 图片流式与普通流式的默认超时不同,运维如果只关注通用配置可能忽略图片专用值。→ [Mitigation] 在配置示例中明确标注图片流式专用默认值和用途。
- [Risk] 断连后继续读取可能导致日志中出现“客户端断开但最终成功”的状态。→ [Mitigation] 保留现有图片计费结果返回语义,同时让调用方在结果与错误并存时优先使用结果对象。

View File

@@ -1,25 +0,0 @@
## Why
图片流式路径目前没有和普通 Responses 流式一致的断连续写策略,也没有独立于普通流式的超时控制。由于图片生成耗时更长,如果继续沿用普通流式处理方式,客户端断开时容易中断上游读取,影响图片产物收集与按图计费的准确性。
## What Changes
- 为 OpenAI Images API 和 `Responses + image_generation` 流式路径补充独立的上游续读策略,客户端断开后继续 drain 上游,尽量保留最终图片结果和计费结果。
- 为图片流式路径使用独立的流数据间隔超时与 keepalive 策略,默认比普通流式更长,不新增页面配置项。
- 保持现有普通流式配置与行为不变,避免影响已经配置好的普通文本分组。
- 让图片流式路径在超时、断连、写入失败等场景下保持图片计费语义一致。
## Capabilities
### New Capabilities
- `image-stream-resilience`: 图片流式路径的断连续读、独立超时和计费保留能力。
### Modified Capabilities
- `image-generation-billing-accounting`: 图片流式结果计数和计费结果的稳定性行为发生改变,但计费契约不变。
## Impact
- 影响 `backend/internal/service/openai_images.go``backend/internal/service/openai_images_responses.go` 的流式实现。
- 影响 `backend/internal/config/config.go``deploy/config.example.yaml` 中图片流式默认值和校验逻辑。
- 影响 `backend/internal/service/openai_images_test.go``backend/internal/config/config_test.go` 以及新增的图片流式稳定性测试。
- 不新增前端页面设置,不改变普通流式配置项名称和语义。

View File

@@ -1,53 +0,0 @@
## ADDED Requirements
### Requirement: Image stream resilience
The system SHALL keep image generation stream processing active after downstream client disconnects so long as upstream reading can continue, in order to preserve final image outputs and billing results.
#### Scenario: Images API stream survives downstream disconnect
- **WHEN** `/v1/images/generations` is streamed to a client
- **AND** the downstream writer returns an error before the upstream stream completes
- **THEN** the service continues draining the upstream stream
- **AND** it still counts final image outputs if the upstream later emits them
- **AND** the request can still complete with image billing metadata
#### Scenario: Responses image tool stream survives downstream disconnect
- **WHEN** a `/v1/responses` request uses `image_generation` and is streamed to a client
- **AND** the downstream writer returns an error before the upstream stream completes
- **THEN** the service continues draining the upstream stream
- **AND** it still counts final image outputs if the upstream later emits them
- **AND** the request can still complete with image billing metadata
#### Scenario: Client disconnect does not force image stream to downgrade to text billing
- **WHEN** a successful image stream request has already produced final image outputs
- **AND** the downstream client disconnects before the final flush
- **THEN** the request remains billed as an image request
- **AND** the image count is preserved in the forward result
### Requirement: Image stream timeout isolation
The system SHALL use image-specific streaming timeout settings for image generation stream paths, and these settings SHALL be independent from the ordinary text streaming timeout values.
#### Scenario: Image stream uses dedicated timeout defaults
- **WHEN** an image generation stream path is executed
- **THEN** it uses the image-specific data interval timeout and keepalive interval defaults
- **AND** it does not rely on the ordinary text stream timeout defaults
#### Scenario: Ordinary stream settings remain unchanged
- **WHEN** a normal non-image streaming request is executed
- **THEN** the existing ordinary stream timeout configuration and behavior remain unchanged
#### Scenario: Image stream timeout is longer than ordinary stream timeout
- **WHEN** the image streaming timeout defaults are compared with the ordinary streaming defaults
- **THEN** the image streaming timeout is configured to allow a longer wait window than ordinary text streaming
### Requirement: Image stream billing consistency
The system SHALL keep the image billing result consistent even when image stream handling uses retries, keepalive writes, or downstream disconnect recovery.
#### Scenario: Final image count is preserved after reconnect-unsafe downstream failure
- **WHEN** the downstream client disconnects after at least one final image output has been observed upstream
- **THEN** the forward result retains the final image count
- **AND** usage recording can still proceed with image billing metadata
#### Scenario: Image stream timeout does not silently switch billing mode
- **WHEN** an image stream times out before any final image output is observed
- **THEN** the request is handled as a failed image stream
- **AND** it does not fall back to ordinary text billing semantics

View File

@@ -1,20 +0,0 @@
## 1. Config and defaults
- [x] 1.1 Add image-specific stream timeout fields to gateway config.
- [x] 1.2 Register image stream timeout defaults in the config loader.
- [x] 1.3 Add config validation for image stream timeout ranges.
- [x] 1.4 Expose image stream timeout defaults in `deploy/config.example.yaml`.
## 2. Image stream runtime behavior
- [x] 2.1 Detach image stream upstream contexts from client cancellation.
- [x] 2.2 Add image-specific data interval timeout handling to `/v1/images/*` streaming.
- [x] 2.3 Add image-specific data interval timeout handling to `Responses + image_generation` streaming.
- [x] 2.4 Preserve upstream draining after downstream write failures in both image stream paths.
## 3. Tests and verification
- [x] 3.1 Add config tests for image stream timeout defaults and validation.
- [x] 3.2 Add image streaming disconnect tests for the Images API path.
- [x] 3.3 Add image streaming disconnect tests for the Responses image tool path.
- [x] 3.4 Run focused Go tests for the touched config and image service paths.

View File

@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours