From 9f2f2738fdf701ca785ccd011a64faf7064eb620 Mon Sep 17 00:00:00 2001 From: anguobao123 <205642466+anguobao123@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:28:35 +0800 Subject: [PATCH 01/64] docs(openai): document force HTTP fallback --- README.md | 26 +++++++++++++++++++ backend/internal/config/config_test.go | 9 +++++++ .../openai_ws_protocol_resolver_test.go | 9 +++++++ deploy/.env.example | 5 ++++ deploy/config.example.yaml | 3 ++- 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 127653ce82..50ffe7e430 100644 --- a/README.md +++ b/README.md @@ -640,6 +640,32 @@ Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment. Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out or mitigating upstream WebSocket issues. +#### Force OpenAI upstream HTTP/SSE + +When an egress proxy or network repeatedly reconnects OpenAI Responses +WebSockets, set the global fallback in the persisted deployment configuration: + +```yaml +gateway: + openai_ws: + force_http: true +``` + +For Compose and Apple container deployments, the equivalent `.env` setting is: + +```bash +GATEWAY_OPENAI_WS_FORCE_HTTP=true +``` + +This selects HTTP/SSE for OpenAI upstream Responses traffic that would +otherwise use WebSocket. It does not change the client-facing protocol or force +HTTP/1.1; configure `gateway.openai_http2.enabled` (or +`GATEWAY_OPENAI_HTTP2_ENABLED=false`) separately when a proxy is incompatible +with HTTP/2. Unlike the account-level `http_bridge` mode, this global fallback +takes effect without enabling `mode_router_v2_enabled`. Keep the setting in the +deployment's persisted `.env` or `config.yaml`, rather than inside a running +container, so it is read again after an image update or container recreation. + #### ⚠️ Important: Creating the Admin Account The initial admin account is **only created via the setup wizard** (served at `http://:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons. diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d8cfe820b1..7cef4dfc1f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -553,6 +553,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) { require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds) } +func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true") + + cfg, err := Load() + require.NoError(t, err) + require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP) +} + func TestLoadDefaultOpenAICompactModel(t *testing.T) { resetViperWithJWTSecret(t) diff --git a/backend/internal/service/openai_ws_protocol_resolver_test.go b/backend/internal/service/openai_ws_protocol_resolver_test.go index 12e047f100..32716a2c70 100644 --- a/backend/internal/service/openai_ws_protocol_resolver_test.go +++ b/backend/internal/service/openai_ws_protocol_resolver_test.go @@ -87,6 +87,15 @@ func TestOpenAIWSProtocolResolver_Resolve(t *testing.T) { require.Equal(t, "account_force_http", decision.Reason) }) + t.Run("全局强制HTTP无需启用mode router", func(t *testing.T) { + cfg := *baseCfg + cfg.Gateway.OpenAIWS.ForceHTTP = true + + decision := NewOpenAIWSProtocolResolver(&cfg).Resolve(openAIOAuthEnabled) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "global_force_http", decision.Reason) + }) + t.Run("全局关闭保持HTTP", func(t *testing.T) { cfg := *baseCfg cfg.Gateway.OpenAIWS.Enabled = false diff --git a/deploy/.env.example b/deploy/.env.example index 6beca88308..76762e303b 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -285,6 +285,11 @@ GATEWAY_FORCE_CODEX_CLI=false GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4 # OpenAI/Codex 等待上游响应头超时(秒);0 表示不使用本地响应头超时截断。 GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=0 +# 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket。 +# 当代理或网络导致上游 WebSocket 反复重连时可设为 true;这不会禁用 HTTP/2。 +# 如需回退 HTTP/1.1,请另行设置 GATEWAY_OPENAI_HTTP2_ENABLED=false。 +# 将此项保存在持久化 .env 中,镜像更新或容器重建后会在启动时重新读取。 +GATEWAY_OPENAI_WS_FORCE_HTTP=false # OpenAI HTTP 上游默认启用 HTTP/2;如需紧急回滚可设为 false。 GATEWAY_OPENAI_HTTP2_ENABLED=true GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=true diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 38a50d0607..cacd07574f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -329,7 +329,8 @@ gateway: # 按账号类型细分开关 oauth_enabled: true apikey_enabled: true - # 全局强制 HTTP(紧急回滚开关) + # 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket(紧急回滚开关)。 + # 此开关不强制 HTTP/1.1;HTTP/2 由 gateway.openai_http2 单独控制。 force_http: false # 允许在 WSv2 下按策略恢复 store=true(默认 false) allow_store_recovery: false From 10081a8127bc361a769f0ec9a54996ac4211a239 Mon Sep 17 00:00:00 2001 From: anguobao123 <205642466+anguobao123@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:39:12 +0800 Subject: [PATCH 02/64] fix(deploy): pass force HTTP setting to containers --- .github/workflows/backend-ci.yml | 1 + deploy/docker-compose.dev.yml | 1 + deploy/docker-compose.local.yml | 1 + deploy/docker-compose.standalone.yml | 1 + deploy/docker-compose.yml | 1 + .../docker-compose-openai-force-http-test.sh | 22 +++++++++++++++++++ 6 files changed, 27 insertions(+) create mode 100644 deploy/tests/docker-compose-openai-force-http-test.sh diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6c4a028c96..bd9c218423 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,7 @@ jobs: /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh /bin/sh deploy/tests/docker-compose-security-test.sh + /bin/sh deploy/tests/docker-compose-openai-force-http-test.sh /bin/sh deploy/tests/docker-runtime-resources-test.sh /bin/sh deploy/test-caddyfile-cache.sh diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 8603f121db..68e45c9be3 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -55,6 +55,7 @@ services: - NO_PROXY=${SUB2API_DEV_NO_PROXY:-127.0.0.1,localhost,::1,postgres,redis,sub2api,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,.local} # OpenAI HTTP upstream protocol/timeout - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 588862e2f2..4306c099f2 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -163,6 +163,7 @@ services: # ======================================================================= # OpenAI HTTP upstream protocol/timeout - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index cd0a008871..66249ed9b4 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -110,6 +110,7 @@ services: # ======================================================================= # OpenAI HTTP upstream protocol/timeout - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 68a51e78dd..34017e4688 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -160,6 +160,7 @@ services: # ======================================================================= # OpenAI HTTP upstream protocol/timeout - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} diff --git a/deploy/tests/docker-compose-openai-force-http-test.sh b/deploy/tests/docker-compose-openai-force-http-test.sh new file mode 100644 index 0000000000..5e8020cd22 --- /dev/null +++ b/deploy/tests/docker-compose-openai-force-http-test.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +expected=' - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}' + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + count=$(grep -Fxc "$expected" "$compose_file" || true) + if [ "$count" -ne 1 ]; then + printf '%s must pass GATEWAY_OPENAI_WS_FORCE_HTTP exactly once to sub2api\n' "$compose_file" >&2 + exit 1 + fi +done + +printf 'docker compose OpenAI force HTTP environment test passed\n' From e2263d25657461b487da64287d628b2b83ff6f4b Mon Sep 17 00:00:00 2001 From: anguobao123 <205642466+anguobao123@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:07:29 +0800 Subject: [PATCH 03/64] fix(deploy): forward documented gateway settings --- .github/workflows/backend-ci.yml | 2 +- deploy/docker-compose.dev.yml | 24 +++++++++++- deploy/docker-compose.local.yml | 24 +++++++++++- deploy/docker-compose.standalone.yml | 24 +++++++++++- deploy/docker-compose.yml | 24 +++++++++++- .../tests/docker-compose-gateway-env-test.sh | 37 +++++++++++++++++++ .../docker-compose-openai-force-http-test.sh | 22 ----------- 7 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 deploy/tests/docker-compose-gateway-env-test.sh delete mode 100644 deploy/tests/docker-compose-openai-force-http-test.sh diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index bd9c218423..ece09c5dc1 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,7 +17,7 @@ jobs: /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh /bin/sh deploy/tests/docker-compose-security-test.sh - /bin/sh deploy/tests/docker-compose-openai-force-http-test.sh + /bin/sh deploy/tests/docker-compose-gateway-env-test.sh /bin/sh deploy/tests/docker-runtime-resources-test.sh /bin/sh deploy/test-caddyfile-cache.sh diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 68e45c9be3..28c1b85166 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -53,7 +53,9 @@ services: - HTTPS_PROXY=${SUB2API_DEV_HTTPS_PROXY:-http://host.docker.internal:7897} - ALL_PROXY=${SUB2API_DEV_ALL_PROXY:-socks5://host.docker.internal:7897} - NO_PROXY=${SUB2API_DEV_NO_PROXY:-127.0.0.1,localhost,::1,postgres,redis,sub2api,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,.local} - # OpenAI HTTP upstream protocol/timeout + # Gateway upstream protocol, scheduling, and image configuration + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} @@ -64,8 +66,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 4306c099f2..b49c646da0 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -159,9 +159,11 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= # OpenAI HTTP upstream protocol/timeout + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} @@ -172,8 +174,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 66249ed9b4..3bed6b77d5 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -106,9 +106,11 @@ services: - ANTIGRAVITY_USER_AGENT_VERSION=${ANTIGRAVITY_USER_AGENT_VERSION:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= # OpenAI HTTP upstream protocol/timeout + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} @@ -119,8 +121,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 34017e4688..e41bc8b14f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -156,9 +156,11 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= # OpenAI HTTP upstream protocol/timeout + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} @@ -169,8 +171,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/tests/docker-compose-gateway-env-test.sh b/deploy/tests/docker-compose-gateway-env-test.sh new file mode 100644 index 0000000000..43f195feff --- /dev/null +++ b/deploy/tests/docker-compose-gateway-env-test.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +gateway_variables=$(mktemp "${TMPDIR:-/tmp}/sub2api-gateway-env.XXXXXX") +cleanup() { + rm -f "$gateway_variables" +} +trap cleanup EXIT HUP INT TERM + +awk ' + /^GATEWAY_[A-Z0-9_]+=/ { + separator = index($0, "=") + print substr($0, 1, separator - 1) "\t" substr($0, separator + 1) + } +' deploy/.env.example > "$gateway_variables" + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + tab=$(printf '\t') + while IFS="$tab" read -r key value; do + expected=$(printf ' - %s=${%s:-%s}' "$key" "$key" "$value") + count=$(grep -Fxc "$expected" "$compose_file" || true) + if [ "$count" -ne 1 ]; then + printf '%s must pass %s with the documented default exactly once\n' "$compose_file" "$key" >&2 + exit 1 + fi + done < "$gateway_variables" +done + +printf 'docker compose Gateway environment test passed\n' diff --git a/deploy/tests/docker-compose-openai-force-http-test.sh b/deploy/tests/docker-compose-openai-force-http-test.sh deleted file mode 100644 index 5e8020cd22..0000000000 --- a/deploy/tests/docker-compose-openai-force-http-test.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -set -eu - -repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) -cd "$repo_root" - -expected=' - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false}' - -for compose_file in \ - deploy/docker-compose.yml \ - deploy/docker-compose.local.yml \ - deploy/docker-compose.standalone.yml \ - deploy/docker-compose.dev.yml -do - count=$(grep -Fxc "$expected" "$compose_file" || true) - if [ "$count" -ne 1 ]; then - printf '%s must pass GATEWAY_OPENAI_WS_FORCE_HTTP exactly once to sub2api\n' "$compose_file" >&2 - exit 1 - fi -done - -printf 'docker compose OpenAI force HTTP environment test passed\n' From ee62dfbaf1d9623682df2593e4231f1d377efa5e Mon Sep 17 00:00:00 2001 From: lbyxiaolizi Date: Sat, 22 Aug 2026 14:10:01 +0800 Subject: [PATCH 04/64] fix(proxy): support bracketed IPv6 hosts in batch proxy URL parsing The quick-add parser rejected every IPv6 proxy: the host group [^:]+ cannot match IPv6 literals (colons) and the pattern had no bracketed form, so lines like socks5://[2001:db8::1]:1080 were reported invalid. Add a bracketed-IPv6 host alternative and strip the brackets before storing; the backend re-brackets via net.JoinHostPort when building the proxy URL. Bare (unbracketed) IPv6 stays rejected because it is ambiguous with host:port. Also add a regression test. --- frontend/src/views/admin/ProxiesView.vue | 15 ++++-- .../admin/__tests__/ProxiesView.ipv6.spec.ts | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 0572cc254b..a5cd50f07e 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -1276,6 +1276,7 @@ const handleDataImported = () => { } // Parse proxy URL: protocol://user:pass@host:port or protocol://host:port +// Host may be a domain, IPv4, or bracketed IPv6 ([2001:db8::1]). const parseProxyUrl = ( line: string ): { @@ -1288,20 +1289,26 @@ const parseProxyUrl = ( const trimmed = line.trim() if (!trimmed) return null - // Regex to parse proxy URL (supports http, https, socks5, socks5h) - const regex = /^(https?|socks5h?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i + // Regex to parse proxy URL (supports http, https, socks5, socks5h). + // Host alternatives: [bracketed-IPv6] | hostname/IPv4 (colon-free, so the + // match stops before the final :port). + const regex = + /^(https?|socks5h?):\/\/(?:([^:@\[\]]+):([^@\[\]]+)@)?(\[[0-9a-f:.]+\]|[^:\[\]]+):(\d+)$/i const match = trimmed.match(regex) if (!match) return null - const [, protocol, username, password, host, port] = match + const [, protocol, username, password, rawHost, port] = match const portNum = parseInt(port, 10) if (portNum < 1 || portNum > 65535) return null + // Strip brackets from IPv6 literals; the backend re-brackets via net.JoinHostPort. + const host = rawHost.replace(/^\[|\]$/g, '').trim() + return { protocol: protocol.toLowerCase() as ProxyProtocol, - host: host.trim(), + host, port: portNum, username: username?.trim() || '', password: password?.trim() || '' diff --git a/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts b/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts new file mode 100644 index 0000000000..6cea0ac86c --- /dev/null +++ b/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +// parseProxyUrl is not exported; assert on the source to lock in the +// bracketed-IPv6 host alternative and exercise the regex directly. +const source = readFileSync( + resolve(process.cwd(), 'src/views/admin/ProxiesView.vue'), + 'utf8' +) + +function extractRegex(): RegExp { + const match = source.match(/const regex =\s*\n?\s*(\/\^\(https\?[^;\n]+\/i)\n/) + expect(match, 'parseProxyUrl regex not found in ProxiesView.vue').toBeTruthy() + return new RegExp((match as RegExpMatchArray)[1].slice(1, -2), 'i') +} + +describe('proxy batch URL parsing (IPv6 support)', () => { + it('keeps bracketed-IPv6 host alternative in the regex', () => { + expect(source).toContain('\\[[0-9a-f:.]+\\]') + }) + + const regex = extractRegex() + + it.each([ + ['socks5://[2001:db8::1]:1080', true], + ['socks5h://[2001:db8::1]:1080', true], + ['http://[::1]:8080', true], + ['socks5://user:pass@[2001:db8::1]:1080', true], + ['socks5://proxy.example.com:1080', true], + ['http://192.168.1.1:8080', true], + ['socks5://user:pass@proxy.example.com:1080', true], + // bare IPv6 without brackets is ambiguous with host:port — rejected + ['socks5://2001:db8::1:1080', false], + // unsupported schemes / malformed ports stay invalid + ['ftp://example.com:21', false], + ['socks5://example.com:port', false] + ])('%s => %s', (line, expected) => { + expect(regex.test(line)).toBe(expected) + }) + + it('extracts bare IPv6 host without brackets', () => { + const m = 'socks5://user:pass@[2001:db8::1]:1080'.match(regex) + expect(m).toBeTruthy() + const [, , username, password, rawHost, port] = m as RegExpMatchArray + expect(username).toBe('user') + expect(password).toBe('pass') + expect(rawHost.replace(/^\[|\]$/g, '')).toBe('2001:db8::1') + expect(port).toBe('1080') + }) +}) From 5dfad32b8779cdff4008db929061bfaed3acceed Mon Sep 17 00:00:00 2001 From: Yoga Sakti Date: Sat, 22 Aug 2026 13:22:21 +0700 Subject: [PATCH 05/64] fix(frontend): accept unlimited (0) user concurrency in the edit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin user edit modal rejected concurrency < 1, so a user whose concurrency is already 0 could not be saved at all — the guard runs before the request, blocking notes, password, role and RPM edits on that user too. Everywhere else already treats 0 as unlimited: the gateway skips slot limiting when maxConcurrency <= 0 (ConcurrencyService.AcquireUserSlot), the batch limits endpoint binds concurrency with min=0, and the bulk edit modal only rejects negative values. Reject negative and non-integer values instead, mirror the RPM field with min/step and a "0 = unlimited" placeholder and hint, and rename the error key to match its new meaning. Account concurrency is unchanged. --- .../components/admin/user/UserEditModal.vue | 16 +++- .../user/__tests__/UserEditModal.spec.ts | 90 +++++++++++++++++++ .../src/i18n/locales/en/admin/overview.ts | 4 +- .../src/i18n/locales/zh/admin/overview.ts | 4 +- 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 4d02c4d6e0..45161be629 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -43,7 +43,16 @@
- + +

{{ t('admin.users.form.concurrencyHint') }}

@@ -132,8 +141,9 @@ const handleUpdateUser = async () => { appStore.showError(t('admin.users.emailRequired')) return } - if (form.concurrency < 1) { - appStore.showError(t('admin.users.concurrencyMin')) + // 0 = 不限制,与网关 (AcquireUserSlot: maxConcurrency <= 0) 和批量改限额一致 + if (!Number.isInteger(form.concurrency) || form.concurrency < 0) { + appStore.showError(t('admin.users.concurrencyNonNegative')) return } const userId = props.user.id diff --git a/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts new file mode 100644 index 0000000000..5d04b55f59 --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import UserEditModal from '../UserEditModal.vue' + +const { update, updateUserAttributeValues, showSuccess, showError } = vi.hoisted(() => ({ + update: vi.fn(), + updateUserAttributeValues: vi.fn(), + showSuccess: vi.fn(), + showError: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + users: { update }, + userAttributes: { updateUserAttributeValues } + } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showSuccess, showError }) +})) + +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ copyToClipboard: vi.fn() }) +})) + +// useStepUp pulls in the API client, which needs the real i18n instance. +vi.mock('vue-i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key + }) +})) + +const mountModal = (concurrency: number) => mount(UserEditModal, { + props: { + show: true, + user: { id: 7, email: 'user@example.test', username: 'user', notes: '', role: 'user', concurrency, rpm_limit: 0 } as never + }, + global: { + stubs: { + BaseDialog: { + props: ['show', 'title'], + template: '
' + }, + Select: true, + Icon: true, + UserAttributeForm: true, + TotpStepUpDialog: true + } + } +}) + +describe('UserEditModal concurrency', () => { + beforeEach(() => { + update.mockReset() + updateUserAttributeValues.mockReset() + showSuccess.mockReset() + showError.mockReset() + update.mockResolvedValue({}) + }) + + // Regression coverage for issue #5977: the gateway treats concurrency <= 0 as + // unlimited (AcquireUserSlot) and both the batch limits endpoint and the bulk + // edit modal accept 0, so this dialog must not be the only place that rejects + // it — doing so blocked every other edit on such a user. + it('saves an unlimited (0) concurrency instead of blocking the whole form', async () => { + const wrapper = mountModal(0) + + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(showError).not.toHaveBeenCalled() + expect(update).toHaveBeenCalledWith(7, expect.objectContaining({ concurrency: 0 })) + expect(wrapper.emitted('success')).toBeTruthy() + }) + + it('still rejects a negative concurrency', async () => { + const wrapper = mountModal(3) + + await wrapper.get('[data-test="concurrency-input"]').setValue('-1') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.users.concurrencyNonNegative') + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index a7521aebed..9b4040099d 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -513,6 +513,8 @@ export default { statusLabel: 'Status', selectStatus: 'Select status', rpmLimit: 'Requests Per Minute (RPM)', + concurrencyPlaceholder: '0 = unlimited', + concurrencyHint: 'Max concurrent requests for this user; 0 = unlimited.', rpmLimitPlaceholder: '0 = unlimited', rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.' }, @@ -577,7 +579,7 @@ export default { failedToToggle: 'Failed to update user status', failedToLoadApiKeys: 'Failed to load user API keys', emailRequired: 'Please enter email', - concurrencyMin: 'Concurrency must be at least 1', + concurrencyNonNegative: 'Concurrency cannot be negative; 0 = unlimited', amountRequired: 'Please enter a valid amount', insufficientBalance: 'Insufficient balance', adjustBalance: 'Adjust Balance', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 00ee1fbfc1..c2e842e8b0 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -578,6 +578,8 @@ export default { statusLabel: '状态', selectStatus: '选择状态', rpmLimit: '每分钟请求数 (RPM)', + concurrencyPlaceholder: '0 表示不限制', + concurrencyHint: '该用户的最大并发请求数,0 = 不限制', rpmLimitPlaceholder: '0 表示不限制', rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效' }, @@ -598,7 +600,7 @@ export default { failedToSave: '保存用户失败', failedToAdjust: '调整失败', emailRequired: '请输入邮箱', - concurrencyMin: '并发数不能小于1', + concurrencyNonNegative: '并发数不能为负数,0 表示不限制', amountRequired: '请输入有效金额', insufficientBalance: '余额不足', setAllowedGroups: '设置允许分组', From d493ce0bb2959c51c8aa0da32269adccb22302c1 Mon Sep 17 00:00:00 2001 From: Jarl Date: Sat, 22 Aug 2026 15:06:40 +0800 Subject: [PATCH 06/64] fix(openai): scope codex identity to oauth account --- .../openai_agent_identity_compat_test.go | 6 +- .../internal/service/openai_alpha_search.go | 7 +- .../service/openai_alpha_search_test.go | 14 +- .../service/openai_codex_account_identity.go | 275 ++++++++++++++++++ .../openai_codex_account_identity_test.go | 230 +++++++++++++++ .../service/openai_compat_model_test.go | 2 +- .../openai_gateway_chat_completions.go | 6 +- .../service/openai_gateway_forward.go | 30 +- .../service/openai_gateway_messages.go | 6 +- .../service/openai_gateway_passthrough.go | 14 +- .../service/openai_setup_token_compat_test.go | 2 +- .../service/openai_ws_forwarder_ingress.go | 51 ++-- ...penai_ws_forwarder_ingress_session_test.go | 2 +- .../service/openai_ws_forwarder_payload.go | 5 +- .../openai_ws_forwarder_success_test.go | 20 +- .../service/openai_ws_forwarder_v2.go | 8 +- .../openai_ws_http_bridge_resume_test.go | 12 +- .../openai_ws_v2_passthrough_adapter.go | 19 +- 18 files changed, 658 insertions(+), 51 deletions(-) create mode 100644 backend/internal/service/openai_codex_account_identity.go create mode 100644 backend/internal/service/openai_codex_account_identity_test.go diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index a8f234f58c..2520eaaaeb 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -133,8 +133,8 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id")) require.NotEqual(t, "client-session", req.Header.Get("session_id")) require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-session"), req.Header.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-conversation"), req.Header.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id")) requestBody, err := io.ReadAll(req.Body) require.NoError(t, err) require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`) @@ -147,7 +147,7 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "chatgpt_account_id": "account-oauth-passthrough", + "chatgpt_account_id": "account-agent-passthrough", }, } oauthRecorder := httptest.NewRecorder() diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index c22545f5f1..1a36cc4f1a 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -30,6 +30,9 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if s == nil || c == nil || account == nil { return nil, fmt.Errorf("service, context, and account are required") } + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } modelResult := gjson.GetBytes(body, "model") requestedModel := strings.TrimSpace(modelResult.String()) if modelResult.Type != gjson.String || requestedModel == "" { @@ -272,10 +275,11 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { - isolated := isolateOpenAISessionID(apiKeyID, sessionID) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID) req.Header.Set("Session_ID", isolated) req.Header.Set("Conversation_ID", isolated) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil @@ -393,6 +397,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index b5d37d1dd8..8521c2160e 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -59,6 +59,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { c.Request.Header.Set("User-Agent", codexCLIUserAgent) c.Request.Header.Set("Originator", "codex_cli_rs") c.Request.Header.Set("Version", "0.144.1") + c.Request.Header.Set("X-Codex-Turn-Metadata", `{"session_id":"search-session","turn_id":"search-turn"}`) upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, @@ -92,6 +93,14 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "session", "search-session"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(), + ) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "search-turn"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.JSONEq(t, string(body), string(upstream.lastBody)) } @@ -156,7 +165,10 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) - require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "turn-1"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State")) diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go new file mode 100644 index 0000000000..d0e336ec6c --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity.go @@ -0,0 +1,275 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const codexAccountIdentityNamespaceVersion = "v1" + +const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source" + +// prepareCodexAccountIdentitySource resolves credential shadows once per selected +// attempt. The handler reuses gin.Context across failover attempts, so every entry +// point overwrites the staged source before projecting outbound identity. +func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) { + source := account + if account != nil && account.IsShadow() { + resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, err + } + source = resolved + } + if c != nil { + c.Set(codexAccountIdentitySourceContextKey, source) + } + return source, nil +} + +func codexAccountIdentitySource(c *gin.Context, fallback *Account) *Account { + if c != nil { + if staged, ok := c.Get(codexAccountIdentitySourceContextKey); ok { + if source, ok := staged.(*Account); ok && source != nil { + return source + } + } + } + return fallback +} + +// codexAccountIdentityNamespace returns a stable, credential-scoped namespace. +// Multiple local rows that use the same ChatGPT account intentionally share the +// same namespace. Setup tokens use an irreversible bearer fingerprint because +// they have no refresh lifecycle or imported account metadata. Refreshable OAuth +// otherwise falls back only to a persistent fingerprint seed: local row IDs are +// deployment-relative and must never become upstream identity. +func codexAccountIdentityNamespace(account *Account) string { + if account == nil || !account.IsOpenAIOAuthLike() { + return "" + } + if upstreamAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); upstreamAccountID != "" { + if upstreamUserID := strings.TrimSpace(account.GetCredential("chatgpt_user_id")); upstreamUserID != "" { + return "chatgpt:" + upstreamAccountID + ":user:" + upstreamUserID + } + return "chatgpt:" + upstreamAccountID + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + return "seed:" + seed + } + if account.Type == AccountTypeSetupToken { + if token := strings.TrimSpace(account.GetOpenAIAccessToken()); token != "" { + sum := sha256.Sum256([]byte("openai-setup-token:" + token)) + return fmt.Sprintf("setup-token:%x", sum[:16]) + } + } + return "" +} + +// isolateOpenAIUpstreamSessionID preserves the existing API-key isolation while +// adding the selected OAuth credential namespace. A scheduler failover therefore +// cannot send the same session/conversation identity through two upstream accounts. +func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + namespace := codexAccountIdentityNamespace(account) + if namespace == "" { + return isolateOpenAISessionID(apiKeyID, raw) + } + sum := sha256.Sum256([]byte(fmt.Sprintf("u%d:a%s:%s", apiKeyID, namespace, raw))) + return fmt.Sprintf("%x", sum[:8]) +} + +func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string { + raw = strings.TrimSpace(raw) + namespace := codexAccountIdentityNamespace(account) + if raw == "" || namespace == "" { + return raw + } + return deriveStableUUIDv4(fmt.Sprintf( + "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s", + codexAccountIdentityNamespaceVersion, + apiKeyID, + namespace, + kind, + raw, + )) +} + +var codexAccountIdentityFields = []struct { + name string + kind string +}{ + {name: "installation_id", kind: "installation"}, + {name: "x-codex-installation-id", kind: "installation"}, + {name: "session_id", kind: "session"}, + {name: "session-id", kind: "session"}, + {name: "thread_id", kind: "thread"}, + {name: "thread-id", kind: "thread"}, + {name: "turn_id", kind: "turn"}, + {name: "turn-id", kind: "turn"}, + {name: "window_id", kind: "window"}, + {name: "x-codex-window-id", kind: "window"}, + {name: "x-client-request-id", kind: "request"}, +} + +func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool { + if values == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + for _, field := range codexAccountIdentityFields { + raw, ok := values[field.name].(string) + if !ok || strings.TrimSpace(raw) == "" { + continue + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw) + if next != raw { + values[field.name] = next + changed = true + } + } + return changed +} + +func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool { + raw, ok := values[openAIWSTurnMetadataHeader].(string) + if !ok || strings.TrimSpace(raw) == "" { + return false + } + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + return false + } + if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + return false + } + rebuilt, err := json.Marshal(metadata) + if err != nil { + return false + } + values[openAIWSTurnMetadataHeader] = string(rebuilt) + return true +} + +func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool { + if requestBody == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + clientMetadata, _ := requestBody["client_metadata"].(map[string]any) + originalBodySessionID := "" + if clientMetadata != nil { + originalBodySessionID, _ = clientMetadata["session_id"].(string) + if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) { + changed = true + } + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + changed = true + } + } + if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" { + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if next != raw { + requestBody["prompt_cache_key"] = next + changed = true + } + } + return changed +} + +// applyCodexAccountIdentityClientMetadataRaw scopes only the small identity +// subobjects with gjson/sjson. The passthrough hot path never unmarshals the +// potentially multi-megabyte request body. +func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) { + if len(body) == 0 || codexAccountIdentityNamespace(account) == "" { + return body, false, nil + } + root := gjson.ParseBytes(body) + if !root.IsObject() { + return body, false, nil + } + + next := body + changed := false + originalBodySessionID := "" + if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { + clientMetadata := map[string]any{} + if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil { + return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err) + } + originalBodySessionID, _ = clientMetadata["session_id"].(string) + metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + metadataChanged = true + } + if metadataChanged { + raw, err := json.Marshal(clientMetadata) + if err != nil { + return body, false, fmt.Errorf("encode account-scoped client_metadata: %w", err) + } + var setErr error + next, setErr = sjson.SetRawBytes(next, "client_metadata", raw) + if setErr != nil { + return body, false, fmt.Errorf("splice account-scoped client_metadata: %w", setErr) + } + changed = true + } + } + if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" { + raw := promptCacheKey.String() + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if scoped != raw { + rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped) + if err != nil { + return body, false, fmt.Errorf("splice account-scoped prompt_cache_key: %w", err) + } + next = rewritten + changed = true + } + } + return next, changed, nil +} + +func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) { + if headers == nil || codexAccountIdentityNamespace(account) == "" { + return + } + for _, field := range codexAccountIdentityFields { + // Underscore session/conversation headers are rebuilt separately from the + // prompt cache key by each request builder. + if field.name == "session_id" { + continue + } + raw := strings.TrimSpace(headers.Get(field.name)) + if raw != "" { + headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)) + } + } + if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + if rebuilt, err := json.Marshal(metadata); err == nil { + headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) + } + } + } +} diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go new file mode 100644 index 0000000000..ffceba2d39 --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -0,0 +1,230 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type codexAccountIdentityRepoStub struct { + AccountRepository + account *Account +} + +func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) { + return s.account, nil +} + +func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`) + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + + first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + firstAgain, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + second, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account19, 77) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, string(first), string(firstAgain)) + + paths := []string{ + "prompt_cache_key", + "client_metadata.x-codex-installation-id", + "client_metadata.session_id", + "client_metadata.thread_id", + "client_metadata.x-codex-window-id", + } + for _, path := range paths { + require.NotEqual(t, gjson.GetBytes(body, path).String(), gjson.GetBytes(first, path).String(), path) + require.NotEqual(t, gjson.GetBytes(first, path).String(), gjson.GetBytes(second, path).String(), path) + } + require.Equal(t, gjson.GetBytes(first, "prompt_cache_key").String(), gjson.GetBytes(first, "client_metadata.session_id").String()) + + var embeddedFirst map[string]any + var embeddedSecond map[string]any + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst)) + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond)) + for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} { + require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field) + } +} + +func TestCodexAccountIdentityNamespaceUsesStableCredentialSource(t *testing.T) { + firstRow := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + secondRow := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + require.Equal(t, codexAccountIdentityNamespace(firstRow), codexAccountIdentityNamespace(secondRow)) + + firstUser := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + sameUser := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + secondUser := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-2"}} + require.Equal(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(sameUser)) + require.NotEqual(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(secondUser)) + + seed := "11111111-1111-4111-8111-111111111111" + seeded := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}} + require.Equal(t, "seed:"+seed, codexAccountIdentityNamespace(seeded)) + + // Local row IDs repeat across independent deployments, so they are not a + // safe fallback for upstream identity. + require.Empty(t, codexAccountIdentityNamespace(&Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) + + setupTokenA := &Account{ID: 30, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenADuplicate := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenB := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-b"}} + setupNamespace := codexAccountIdentityNamespace(setupTokenA) + require.NotEmpty(t, setupNamespace) + require.NotContains(t, setupNamespace, "setup-token-a") + require.Equal(t, setupNamespace, codexAccountIdentityNamespace(setupTokenADuplicate)) + require.NotEqual(t, setupNamespace, codexAccountIdentityNamespace(setupTokenB)) +} + +func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + parentID := int64(11) + parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "team-account", + "chatgpt_user_id": "user-1", + }} + shadow := &Account{ID: 111, ParentAccountID: &parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + service := &OpenAIGatewayService{accountRepo: &codexAccountIdentityRepoStub{account: parent}} + + resolved, err := service.prepareCodexAccountIdentitySource(context.Background(), c, shadow) + require.NoError(t, err) + require.Same(t, parent, resolved) + require.Same(t, parent, codexAccountIdentitySource(c, shadow)) + + req, err := service.buildUpstreamRequest( + context.Background(), c, shadow, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id")) + + next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "other-account", + "chatgpt_user_id": "user-2", + }} + resolved, err = service.prepareCodexAccountIdentitySource(context.Background(), c, next) + require.NoError(t, err) + require.Same(t, next, resolved) + require.Same(t, next, codexAccountIdentitySource(c, shadow)) +} + +func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Set("api_key_id", int64(77)) + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("x-client-request-id", "client-request") + + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + service := &OpenAIGatewayService{} + build := func(account *Account) http.Header { + headers, _, err := service.buildOpenAIWSHeaders( + context.Background(), c, account, "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "client-session", "", "", + ) + require.NoError(t, err) + return headers + } + + first := build(account11) + firstAgain := build(account11) + second := build(account19) + for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} { + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), header) + require.NotEqual(t, first.Get(header), second.Get(header), header) + } + + httpRequest, err := service.buildUpstreamRequest( + context.Background(), c, account11, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key") +} + +func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{} + body := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`) + + build := func(accountID int64, chatgptAccountID string) http.Header { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 77}) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.0") + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("session-id", "client-session") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-client-request-id", "client-request") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","turn_id":"client-turn","window_id":"client-window"}`) + + account := &Account{ + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": chatgptAccountID, + }, + } + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "oauth-token", true, "client-session", true, + ) + require.NoError(t, err) + return req.Header + } + + first := build(11, "chatgpt-account-11") + firstAgain := build(11, "chatgpt-account-11") + second := build(19, "chatgpt-account-19") + + identityHeaders := []string{ + "x-codex-installation-id", + "x-codex-window-id", + "session-id", + "session_id", + "conversation_id", + "thread-id", + "x-client-request-id", + "x-codex-turn-metadata", + } + checked := 0 + for _, header := range identityHeaders { + if first.Get(header) == "" && second.Get(header) == "" { + continue + } + checked++ + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), "same account must retain stable identity: %s", header) + require.NotEqual(t, first.Get(header), second.Get(header), "account failover must rotate upstream identity: %s", header) + } + require.GreaterOrEqual(t, checked, 5, "test must exercise the real outbound identity surface") +} diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 7ccfbdeadd..b52a616f85 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1010,7 +1010,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 51ee5a252d..bf55cf185c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) @@ -260,6 +263,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } else if promptCacheKey != "" { reqBody["prompt_cache_key"] = promptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) responsesBody, err = json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("remarshal after codex transform: %w", err) @@ -313,7 +317,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if promptCacheKey != "" { apiKeyID := getAPIKeyIDFromContext(c) - upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))) + upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey))) } // 7. Send request diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 957d38a8c7..5a6a233374 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -24,6 +24,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body @@ -247,6 +250,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } bodyModified := false + clientPromptCacheKey := promptCacheKey var reqBody map[string]any ensureReqBody := func() (map[string]any, error) { if requestView.HasPatches() { @@ -473,6 +477,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if !isCompactRequest && applyCodexClientMetadata(decoded, account) { markDecodedModified() } + if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok { + clientPromptCacheKey = currentClientPromptCacheKey + } + // Account namespace is orthogonal to fingerprint convergence: preserve + // each client's identity cardinality, but never reuse it across OAuth + // credentials after scheduler failover. + if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) { + markDecodedModified() + } stageCodexFingerprintIDs(c, nil) // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 @@ -495,7 +508,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + if strings.TrimSpace(clientPromptCacheKey) != "" { + // The body now carries an account-scoped value. Keep the original here + // so the header builder derives the same namespace exactly once. + promptCacheKey = clientPromptCacheKey + } else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + // Fingerprint convergence may inject a default key when the client did + // not provide one; preserve that existing fallback. promptCacheKey = currentPromptCacheKey } else if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey @@ -768,6 +787,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco c, account, wsReqBody, + clientPromptCacheKey, token, wsDecision, isCodexCLI, @@ -1313,12 +1333,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession)) } else { req.Header.Set("accept", "text/event-stream") } if promptCacheKey != "" { - isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey) req.Header.Set("session_id", isolated) if !compatMessagesBridge || clientConversationID != "" { req.Header.Set("conversation_id", isolated) @@ -1342,6 +1362,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + // 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把 + // 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。 + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 applyStagedCodexFingerprintHeaders(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index bee1eb1cde..a02db3d965 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -35,6 +35,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 @@ -227,6 +230,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), apiKeyID) delete(reqBody, "prompt_cache_key") if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey) @@ -329,7 +333,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Override session_id with a deterministic UUID derived from the isolated // session key, ensuring different API keys produce different upstream sessions. if account.Platform != PlatformGrok && promptCacheKey != "" { - isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)) + isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)) upstreamReq.Header.Set("session_id", isolatedSessionID) if upstreamReq.Header.Get("conversation_id") != "" { upstreamReq.Header.Set("conversation_id", isolatedSessionID) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 23c283529d..f4c196a20b 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -180,6 +180,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } reqStream = gjson.GetBytes(body, "stream").Bool() + accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return nil, scopeErr + } + if accountScoped { + body = accountScopedBody + } + stageCodexFingerprintIDs(c, nil) // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 @@ -667,10 +675,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( clientConversationID = promptCacheKey } if clientSessionID != "" { - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID)) } if clientConversationID != "" { - req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID)) + req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID)) } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -687,6 +695,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: // 会话隔离之后、终态身份收口之前)。 diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 7405deb419..8f2f2bfcee 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -227,7 +227,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.NotNil(t, secondResult) require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui") } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b4a8752347..16942ebff2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -86,6 +86,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // A handler may reuse the same gin context across account failover attempts. // Never let an OAuth attempt's response aliases leak into the next account. setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return err + } if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } @@ -180,15 +183,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) type openAIWSClientPayload struct { - payloadRaw []byte - rawForHash []byte - promptCacheKey string - previousResponseID string - originalModel string - imageBillingModel string - imageSizeTier string - imageInputSize string - payloadBytes int + payloadRaw []byte + accountIdentitySourceRaw []byte + rawForHash []byte + promptCacheKey string + previousResponseID string + originalModel string + imageBillingModel string + imageSizeTier string + imageInputSize string + payloadBytes int } ingressSessionOriginalModel := "" @@ -307,6 +311,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + accountIdentitySourceRaw := append([]byte(nil), normalized...) + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + normalized = accountScopedPayload + } if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) { litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) if liteErr != nil { @@ -449,15 +461,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ingressSessionOriginalModel = originalModel return openAIWSClientPayload{ - payloadRaw: normalized, - rawForHash: trimmed, - promptCacheKey: promptCacheKey, - previousResponseID: previousResponseID, - originalModel: originalModel, - imageBillingModel: imageBillingModel, - imageSizeTier: imageSizeTier, - imageInputSize: imageInputSize, - payloadBytes: len(normalized), + payloadRaw: normalized, + accountIdentitySourceRaw: accountIdentitySourceRaw, + rawForHash: trimmed, + promptCacheKey: promptCacheKey, + previousResponseID: previousResponseID, + originalModel: originalModel, + imageBillingModel: imageBillingModel, + imageSizeTier: imageSizeTier, + imageInputSize: imageInputSize, + payloadBytes: len(normalized), }, nil } @@ -644,7 +657,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( var failoverErr *UpstreamFailoverError if turn > 1 && errors.As(bridgeErr, &failoverErr) && failoverErr != nil { retryPayload, retrySafe, retryPayloadErr := buildOpenAIWSCurrentTurnRetryPayload( - bridgePayloadRaw, + currentBridgePayload.accountIdentitySourceRaw, turnAccountFailoverInput, turnAccountFailoverInputExists, currentBridgePayload.originalModel, diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 868da4776d..11e183a370 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1225,7 +1225,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade t.Fatal("等待 passthrough websocket 结束超时") } - require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader)) require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) require.Len(t, upstreamConn.writes, 1) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 89d0dfc18c..94738221a2 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -121,10 +121,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if account != nil && account.UsesOpenAICodexProtocol() { apiKeyID := getAPIKeyIDFromContext(c) if sessionResolution.SessionID != "" { - headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) + headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID)) } if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID)) + headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID)) } } else { if sessionResolution.SessionID != "" { @@ -140,6 +140,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } + applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.UsesOpenAICodexProtocol() { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 24f23f0d2b..e775104fa2 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -430,7 +430,7 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test require.Empty(t, headers.Get("X-Test")) } -func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessionIdentity(t *testing.T) { +func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedClientSessionIdentity(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -465,10 +465,10 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessi require.NoError(t, err) require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id")) require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id")) - require.Equal(t, "client-window", headers.Get("x-codex-window-id")) - require.Equal(t, "client-session", headers.Get("session-id")) - require.Equal(t, "client-thread", headers.Get("thread-id")) - require.Equal(t, "client-request", headers.Get("x-client-request-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "window", "client-window"), headers.Get("x-codex-window-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), headers.Get("session-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), headers.Get("thread-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "request", "client-request"), headers.Get("x-client-request-id")) } func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { @@ -798,10 +798,10 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) - // OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离, + // OAuth 账号的 session_id/conversation_id 应同时按 API key 和上游账号隔离, // 测试中未设置 api_key 到 context,apiKeyID=0。 - require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) } func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) { @@ -1023,8 +1023,8 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK require.NotNil(t, result) require.Equal(t, "resp_prompt_cache_key", result.RequestID) - // OAuth 账号的 session_id 应被 isolateOpenAISessionID 隔离(apiKeyID=0,未在 context 设置)。 - require.Equal(t, isolateOpenAISessionID(0, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) + // OAuth 账号的 session_id 应同时按 API key 和上游账号隔离(apiKeyID=0)。 + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) require.Empty(t, captureDialer.lastHeaders.Get("conversation_id")) require.NotNil(t, captureConn.lastWrite) require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists()) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 4ecd73d09a..451c74a1ab 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -22,6 +22,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( c *gin.Context, account *Account, reqBody map[string]any, + clientPromptCacheKey string, token string, decision OpenAIWSProtocolDecision, isCodexCLI bool, @@ -72,7 +73,12 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( applyStagedCodexFingerprintClientMetadata(c, account, payload) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") + promptCacheKey := strings.TrimSpace(clientPromptCacheKey) + 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") + } _, hasTools := payload["tools"] debugEnabled := isOpenAIWSModeDebugEnabled() payloadBytes := -1 diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go index 074c08f8ce..1468892bbe 100644 --- a/backend/internal/service/openai_ws_http_bridge_resume_test.go +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -150,11 +150,13 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t account := &Account{ ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, - Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Credentials: map[string]any{"chatgpt_account_id": "account-a", "chatgpt_user_id": "user-a"}, } nextAccount := *account nextAccount.ID = 130 nextAccount.Name = "replacement" + nextAccount.Credentials = map[string]any{"chatgpt_account_id": "account-b", "chatgpt_user_id": "user-b"} serverErrCh := make(chan error, 1) failoverCh := make(chan []byte, 1) @@ -212,7 +214,7 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","prompt_cache_key":"client-session","client_metadata":{"session_id":"client-session","thread_id":"client-thread"},"input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) cancel() require.NoError(t, err) @@ -237,6 +239,8 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t require.Contains(t, input.Raw, "second") require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`)) require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`)) + require.Equal(t, "client-session", gjson.GetBytes(retryPayload, "client_metadata.session_id").String()) + require.Equal(t, "client-thread", gjson.GetBytes(retryPayload, "client_metadata.thread_id").String()) case <-time.After(5 * time.Second): t.Fatal("timed out waiting for current-turn failover") } @@ -249,7 +253,11 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t } require.Len(t, upstream.bodies, 3) require.Contains(t, string(upstream.bodies[0]), "first") + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String()) require.NotContains(t, string(upstream.bodies[2]), "previous_response_id") require.Contains(t, string(upstream.bodies[2]), "second") + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String()) require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader)) } diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 27cd4b44c3..e170889e0f 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -693,6 +693,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) + promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) logOpenAIWSV2Passthrough( "relay_start account_id=%d model=%s previous_response_id=%s first_message_type=%s first_message_bytes=%d", account.ID, @@ -749,6 +750,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = aliasedBody } } + accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + firstClientMessage = accountScopedFirst + } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -789,8 +797,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel) _, initialUpstreamModel := usageMeta.turnModels(initialRequestModel) SetOpsUpstreamModel(c, initialUpstreamModel) - promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) - wsURL, err := s.buildOpenAIResponsesWSURL(account) if err != nil { return fmt.Errorf("build ws url: %w", err) @@ -995,6 +1001,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( payload = aliasedBody } } + if isResponseCreate || eventType == "session.update" { + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + payload = accountScopedPayload + } + } if isResponseCreate { if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) { litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) From 7a09a2eaf7386b299bfa78dfb21df2063f80db9a Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 22 Aug 2026 16:42:56 +0800 Subject: [PATCH 07/64] fix(responses): remove orphan deferred tool flags --- ...ions_responses_bridge_custom_tools_test.go | 12 +++++++++ .../pkg/apicompat/responses_client_tools.go | 23 ++++++++++++++++ .../apicompat/responses_client_tools_test.go | 27 +++++++++++++++++++ .../internal/service/openai_gateway_grok.go | 22 +++++++++++++++ .../service/openai_gateway_grok_test.go | 10 +++++++ .../openai_responses_lite_tools_test.go | 15 +++++++++++ 6 files changed, 109 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index 945900d6d6..ade13d9826 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -280,6 +280,18 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`) } +func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req)) + + out, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + encoded, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(encoded), "defer_loading") + require.Contains(t, string(encoded), `"name":"tool_search"`) +} + // codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行 // tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配 // 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。 diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index b7ce024e8a..b1e2c412f1 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -119,6 +119,9 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, lowered = append(lowered, raw) } } + if stripResponsesDeferredToolFlags(lowered) { + changed = true + } if changed { req["tools"] = lowered } @@ -141,6 +144,26 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, return adapter, changed, nil } +// stripResponsesDeferredToolFlags removes defer_loading only when the final +// declaration list no longer contains the built-in tool_search it requires. +func stripResponsesDeferredToolFlags(tools []any) bool { + if hasResponsesToolSearchDeclaration(tools) { + return false + } + changed := false + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if _, exists := tool["defer_loading"]; exists { + delete(tool, "defer_loading") + changed = true + } + } + return changed +} + // AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on // a follow-up request that omits the session-level tools declaration. An // explicitly present tools field, including an empty or malformed value, diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 5b0dac4356..2b3460755f 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -66,6 +66,33 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( require.Equal(t, "team__send", namespaceCall["name"]) } +func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) { + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + map[string]any{"type": "function", "name": "apply_patch"}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"]) + require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading") +} + +func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) { + tools := []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + } + + require.False(t, stripResponsesDeferredToolFlags(tools)) + require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"]) +} + func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { requestJSON := `{ "tools":[{"type":"tool_search"}], diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index b8ab024014..8b812500d4 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1091,6 +1091,19 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { filteredTools = append(filteredTools, raw) } } + if !grokRawToolsContainType(filteredTools, "tool_search") { + for index, raw := range filteredTools { + if !gjson.GetBytes(raw, "defer_loading").Exists() { + continue + } + cleaned, deleteErr := sjson.DeleteBytes(raw, "defer_loading") + if deleteErr != nil { + return nil, deleteErr + } + filteredTools[index] = cleaned + toolsChanged = true + } + } var err error if len(filteredTools) != len(rawTools) || toolsChanged { @@ -1125,6 +1138,15 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } +func grokRawToolsContainType(tools []json.RawMessage, want string) bool { + for _, tool := range tools { + if strings.TrimSpace(gjson.GetBytes(tool, "type").String()) == want { + return true + } + } + return false +} + func deleteGrokOrphanToolControls(body []byte) ([]byte, error) { var err error for _, field := range []string{"tool_choice", "parallel_tool_calls"} { diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 27c19866c0..e11954a4bc 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -385,6 +385,16 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } +func TestSanitizeGrokResponsesToolsRemovesDeferredFlagsWithToolSearch(t *testing.T) { + body := []byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true},{"type":"function","name":"apply_patch"}]}`) + + patched, err := sanitizeGrokResponsesTools(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="tool_search")`).Exists()) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="shell").defer_loading`).Exists()) + require.True(t, gjson.GetBytes(patched, `tools.#(name=="apply_patch")`).Exists()) +} + func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index b76e97e272..f7fd7309ca 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -58,6 +58,21 @@ func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) } +func TestNormalizeOpenAIResponsesLiteTools_PreservesDeferredFlagsWithToolSearch(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + }, + } + + _, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + tools := reqBody["tools"].([]any) + require.Equal(t, "tool_search", tools[0].(map[string]any)["type"]) + require.Equal(t, true, tools[1].(map[string]any)["defer_loading"]) +} + func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) { reqBody := map[string]any{ "tools": []any{map[string]any{ From cd05772e914077bf5977818cb186bb68b96de53d Mon Sep 17 00:00:00 2001 From: shunwang-crypto Date: Sat, 22 Aug 2026 00:42:57 +0800 Subject: [PATCH 08/64] fix(ops): avoid mixing cgroup and host memory metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Docker + cgroup v2 with no memory limit set, /sys/fs/cgroup/memory.current returns a small container number while /sys/fs/cgroup/memory.max is "max". readCgroupMemoryBytes then returned (used=, total=0, ok=true). collectSystemStats used that container "used" but, being unable to derive a cgroup total, filled the total from the host via gopsutil. The dashboard then computed container_used / host_total, e.g. ~60MB / 23GB ≈ 0.3% — wildly understating real usage. Fix: introduce resolveMemoryStats, which picks a single self-consistent (used, total, percent) trio from ONE source. cgroup metrics are used only when the cgroup exposes both a current usage AND a concrete limit (memory.max != max, so total > 0); otherwise used/total/percent all fall back to the host reading. The two sources are never mixed. - memory.current valid + memory.max = "max" -> all host metrics - memory.current = 512MiB + memory.max = 2GiB -> ~25% from cgroup - no cgroup (bare metal) -> all host metrics CPU metric behavior is unchanged (cgroup attempt then host fallback). Adds ops_metrics_collector_memory_test.go covering all branches. Co-Authored-By: Claude Opus 4.8 --- .../internal/service/ops_metrics_collector.go | 85 ++++++++------ .../ops_metrics_collector_memory_test.go | 110 ++++++++++++++++++ 2 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 backend/internal/service/ops_metrics_collector_memory_test.go diff --git a/backend/internal/service/ops_metrics_collector.go b/backend/internal/service/ops_metrics_collector.go index d4f5c32929..4fc9576044 100644 --- a/backend/internal/service/ops_metrics_collector.go +++ b/backend/internal/service/ops_metrics_collector.go @@ -601,24 +601,11 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle sampleAt := time.Now().UTC() - // Prefer cgroup (container) metrics when available. + // CPU: prefer cgroup (container) metrics, falling back to host metrics when + // cgroup CPU accounting is unavailable. if cpuPct := c.tryCgroupCPUPercent(sampleAt); cpuPct != nil { out.cpuUsagePercent = cpuPct } - - cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() - if cgroupOK { - usedMB := int64(cgroupUsed / bytesPerMB) - out.memoryUsedMB = &usedMB - if cgroupTotal > 0 { - totalMB := int64(cgroupTotal / bytesPerMB) - out.memoryTotalMB = &totalMB - pct := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) - out.memoryUsagePercent = &pct - } - } - - // Fallback to host metrics if cgroup metrics are unavailable (or incomplete). if out.cpuUsagePercent == nil { if cpuPercents, err := cpu.PercentWithContext(ctx, 0, false); err == nil && len(cpuPercents) > 0 { v := roundTo1DP(cpuPercents[0]) @@ -626,32 +613,60 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle } } - // If total memory isn't available from cgroup (e.g. memory.max = "max"), fill total from host. - if out.memoryUsedMB == nil || out.memoryTotalMB == nil || out.memoryUsagePercent == nil { - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil && vm != nil { - if out.memoryUsedMB == nil { - usedMB := int64(vm.Used / bytesPerMB) - out.memoryUsedMB = &usedMB - } - if out.memoryTotalMB == nil { - totalMB := int64(vm.Total / bytesPerMB) - out.memoryTotalMB = &totalMB - } - if out.memoryUsagePercent == nil { - if out.memoryUsedMB != nil && out.memoryTotalMB != nil && *out.memoryTotalMB > 0 { - pct := roundTo1DP(float64(*out.memoryUsedMB) / float64(*out.memoryTotalMB) * 100) - out.memoryUsagePercent = &pct - } else { - pct := roundTo1DP(vm.UsedPercent) - out.memoryUsagePercent = &pct - } - } + // Memory: prefer cgroup (container) metrics, but only when the cgroup exposes + // BOTH a current usage and a concrete limit (memory.max != "max"). When the + // limit is missing or the cgroup data is otherwise incomplete, fall back + // ENTIRELY to host metrics. Never mix a container "used" with a host "total": + // doing so reports a misleadingly tiny percentage (e.g. 60MB / 23GB ~= 0.3%). + cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() + var host *mem.VirtualMemoryStat + if !cgroupOK || cgroupTotal == 0 { + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + host = vm } } + out.memoryUsedMB, out.memoryTotalMB, out.memoryUsagePercent = resolveMemoryStats(cgroupUsed, cgroupTotal, cgroupOK, host) return out, nil } +// resolveMemoryStats picks a single, self-consistent (used, total, percent) +// memory trio from either cgroup (container) or host metrics — never a mix of +// the two. +// +// cgroup metrics are preferred, but only when the cgroup reports BOTH a current +// usage and a concrete limit (i.e. memory.max is a number, not "max", so +// cgroupTotal > 0). If the limit is absent or the cgroup data is otherwise +// incomplete, all three values fall back to the host reading. This avoids the +// classic pitfall of dividing a container "used" by a host "total", which +// wildly understates memory usage (e.g. 60MB / 23GB ~= 0.3%). +func resolveMemoryStats(cgroupUsed, cgroupTotal uint64, cgroupOK bool, host *mem.VirtualMemoryStat) (usedMB *int64, totalMB *int64, usagePercent *float64) { + if cgroupOK && cgroupTotal > 0 { + u := int64(cgroupUsed / bytesPerMB) + t := int64(cgroupTotal / bytesPerMB) + p := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) + return &u, &t, &p + } + + if host == nil { + return nil, nil, nil + } + + u := int64(host.Used / bytesPerMB) + usedMB = &u + if host.Total > 0 { + t := int64(host.Total / bytesPerMB) + totalMB = &t + p := roundTo1DP(float64(host.Used) / float64(host.Total) * 100) + usagePercent = &p + } else { + // Degenerate: host reported no total. Preserve gopsutil's own percentage. + p := roundTo1DP(host.UsedPercent) + usagePercent = &p + } + return usedMB, totalMB, usagePercent +} + func (c *OpsMetricsCollector) tryCgroupCPUPercent(now time.Time) *float64 { usageNanos, ok := readCgroupCPUUsageNanos() if !ok { diff --git a/backend/internal/service/ops_metrics_collector_memory_test.go b/backend/internal/service/ops_metrics_collector_memory_test.go new file mode 100644 index 0000000000..0a1e8873a8 --- /dev/null +++ b/backend/internal/service/ops_metrics_collector_memory_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + + "github.com/shirou/gopsutil/v4/mem" + "github.com/stretchr/testify/require" +) + +const ( + testMiB = 1024 * 1024 + testGiB = 1024 * testMiB +) + +// TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost is the core +// regression: in Docker + cgroup v2 with NO memory limit, memory.current is a +// small container number while memory.max = "max" (so cgroupTotal == 0). The +// old code reported the container "used" against the host "total", producing a +// misleadingly tiny percentage (~0.3%). The fix must fall back ENTIRELY to host +// metrics instead of mixing the two sources. +func TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost(t *testing.T) { + const cgroupUsed = uint64(64573440) // memory.current, ~61 MiB + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(cgroupUsed, 0 /* memory.max = "max" */, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + // Everything must come from the host, not from the cgroup container value. + require.Equal(t, int64(16*1024), *usedMB, "used must be host used, not container used") + require.Equal(t, int64(24*1024), *totalMB, "total must be host total") + require.InDelta(t, 66.7, *pct, 0.05, "percent must be host-derived, not container/host mix") + + // Guard against the specific bug: container used (~61 MiB) vs host total (~0.3%). + require.NotEqual(t, int64(cgroupUsed/testMiB), *usedMB, "must not report the container used value") + require.Greater(t, *pct, 1.0, "percent must not collapse to the ~0.3%% mixed value") +} + +// TestResolveMemoryStatsExplicitContainerLimitUsesCgroup covers the case where +// a real container memory limit is set: memory.current = 512 MiB and +// memory.max = 2 GiB must yield ~25% entirely from cgroup, ignoring the host. +func TestResolveMemoryStatsExplicitContainerLimitUsesCgroup(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, // deliberately different; must be ignored + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(512*testMiB, 2*testGiB, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + require.Equal(t, int64(512), *usedMB) + require.Equal(t, int64(2048), *totalMB) + require.InDelta(t, 25.0, *pct, 0.05) +} + +// TestResolveMemoryStatsNoCgroupUsesHost covers bare-metal / no-cgroup hosts: +// all three values come from the host reading. +func TestResolveMemoryStatsNoCgroupUsesHost(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(16*1024), *usedMB) + require.Equal(t, int64(24*1024), *totalMB) + require.InDelta(t, 66.7, *pct, 0.05) +} + +// TestResolveMemoryStatsNoDataReturnsNil: when neither cgroup nor host data is +// available, all outputs are nil (best-effort, nothing persisted). +func TestResolveMemoryStatsNoDataReturnsNil(t *testing.T) { + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, nil) + require.Nil(t, usedMB) + require.Nil(t, totalMB) + require.Nil(t, pct) +} + +// TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent: degenerate host +// reading with no total still yields a used value and gopsutil's own percentage. +func TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 8 * testGiB, + Total: 0, + UsedPercent: 42.5, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.Nil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(8*1024), *usedMB) + require.InDelta(t, 42.5, *pct, 0.05) +} From 7498d8fdc8e7c0abf11ac313919f68d29be13e8c Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 22 Aug 2026 19:40:19 +0800 Subject: [PATCH 09/64] fix(openai): enforce serial tool calls for Responses Lite --- .../service/openai_gateway_forward.go | 7 +- .../service/openai_responses_lite_tools.go | 63 ++++++++- .../openai_responses_lite_tools_test.go | 131 +++++++++++++++++- ...penai_ws_forwarder_ingress_session_test.go | 25 +++- 4 files changed, 216 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 957d38a8c7..47de60dc69 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -76,9 +76,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) if liteErr != nil { + param := "tools" + var validationErr *openAIResponsesLiteValidationError + if errors.As(liteErr, &validationErr) { + param = validationErr.param + } setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ - "type": "invalid_request_error", "message": liteErr.Error(), "param": "tools", + "type": "invalid_request_error", "message": liteErr.Error(), "param": param, }}) return nil, liteErr } diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index c5c16ecaa1..f374b59bbf 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -7,6 +7,17 @@ import ( "strings" ) +type openAIResponsesLiteValidationError struct { + param string + message string +} + +func (e *openAIResponsesLiteValidationError) Error() string { return e.message } + +func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error { + return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)} +} + // normalizeOpenAIResponsesLiteTools applies the Responses Lite request // contract: reasoning must cover all turns, and private namespace declarations // use the input.additional_tools carrier. Other top-level tools must belong to @@ -16,18 +27,27 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { if reqBody == nil { return false, nil } + if parallel, exists := reqBody["parallel_tool_calls"]; exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil { if _, ok := rawReasoning.(map[string]any); !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } } rawTools, exists := reqBody["tools"] if !exists || rawTools == nil { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } tools, ok := rawTools.([]any) if !ok { - return false, fmt.Errorf("responses Lite requires tools to be an array") + return false, newOpenAIResponsesLiteValidationError("tools", "responses Lite requires tools to be an array") } topLevelTools := make([]any, 0, len(tools)) @@ -57,7 +77,11 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } } if len(namespaceTools) == 0 { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) @@ -73,9 +97,38 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } else { reqBody["tools"] = topLevelTools } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true) +} + +func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { + parallel := reqBody["parallel_tool_calls"] + if !openAIResponsesLiteHasTools(reqBody) { + return changed, nil + } + if parallel == false { + return changed, nil + } + reqBody["parallel_tool_calls"] = false return true, nil } +func openAIResponsesLiteHasTools(reqBody map[string]any) bool { + if tools, ok := reqBody["tools"].([]any); ok && len(tools) > 0 { + return true + } + input, _ := reqBody["input"].([]any) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { + continue + } + if tools, ok := item["tools"].([]any); ok && len(tools) > 0 { + return true + } + } + return false +} + func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) { rawReasoning, exists := reqBody["reasoning"] if !exists || rawReasoning == nil { @@ -84,7 +137,7 @@ func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, er } reasoning, ok := rawReasoning.(map[string]any) if !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } if context, ok := reasoning["context"].(string); ok && context == "all_turns" { return false, nil diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index b76e97e272..2c98302027 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -147,8 +147,95 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin changed, err := normalizeOpenAIResponsesLiteTools(reqBody) require.NoError(t, err) - require.False(t, changed) + require.True(t, changed) require.Len(t, reqBody["tools"], 4) + require.Equal(t, false, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testing.T) { + tests := []struct { + name string + body map[string]any + }{ + { + name: "top-level tools", + body: map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + }, + }, + { + name: "input additional tools", + body: map[string]any{ + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{"type": "namespace", "name": "collaboration"}}, + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed, err := normalizeOpenAIResponsesLiteTools(tt.body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, tt.body["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, true, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { + for _, value := range []any{"false", float64(0), nil, map[string]any{}} { + reqBody := map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) + require.Equal(t, value, reqBody["parallel_tool_calls"]) + } + + reqBody := map[string]any{"parallel_tool_calls": []any{}} + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) +} + +func TestNormalizeOpenAIResponsesLiteTools_ParallelToolCallsIsIdempotent(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) + + changed, err = normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) } func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) { @@ -230,6 +317,8 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t require.False(t, gjson.GetBytes(updated, "tools").Exists()) require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String()) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) } func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { @@ -282,6 +371,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes body := []byte(`{ "model":"gpt-5.6-terra","stream":true,"instructions":"test", "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, "tools":[ {"type":"function","name":"shell","parameters":{"type":"object"}}, {"type":"custom","name":"exec"}, @@ -306,6 +396,45 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + + badRec := httptest.NewRecorder() + badCtx, _ := gin.CreateTestContext(badRec) + badCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + badCtx.Request.Header.Set(responsesLiteHeader, "true") + badUpstream := &httpUpstreamRecorder{} + svc.httpUpstream = badUpstream + + result, err = svc.Forward(context.Background(), badCtx, account, []byte(`{"model":"gpt-5.6-terra","tools":[{"type":"function","name":"shell"}],"parallel_tool_calls":"false"}`)) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, badRec.Code) + require.Equal(t, "invalid_request_error", gjson.Get(badRec.Body.String(), "error.type").String()) + require.Equal(t, "parallel_tool_calls", gjson.Get(badRec.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(badRec.Body.String(), "error.message").String(), "parallel_tool_calls to be a boolean") + require.Nil(t, badUpstream.lastReq) + + for _, malformed := range []struct { + body string + wantParam string + }{ + {body: `{"model":"gpt-5.6-terra","tools":{}}`, wantParam: "tools"}, + {body: `{"model":"gpt-5.6-terra","reasoning":[]}`, wantParam: "reasoning"}, + } { + rec := httptest.NewRecorder() + requestCtx, _ := gin.CreateTestContext(rec) + requestCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + requestCtx.Request.Header.Set(responsesLiteHeader, "true") + + result, err = svc.Forward(context.Background(), requestCtx, account, []byte(malformed.body)) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, malformed.wantParam, gjson.Get(rec.Body.String(), "error.param").String()) + } }) } } diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 868da4776d..7633c9061e 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","sequence":900719925474099312345}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","parallel_tool_calls":true,"sequence":900719925474099312345}`)) cancelWrite() require.NoError(t, err) @@ -750,6 +750,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "stream":false, "previous_response_id":"resp_codex_image_bridge", "reasoning":{"effort":"high"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[ @@ -787,11 +788,23 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, coderws.MessageText, msgType) require.Equal(t, "resp_codex_image_function", gjson.GetBytes(message, "response.id").String()) - _ = clientConn.Close(coderws.StatusNormalClosure, "done") + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "parallel_tool_calls":"false", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"shell"}] + }`)) + cancelWrite() + require.NoError(t, err) select { case serverErr := <-serverErrCh: - require.NoError(t, serverErr) + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Contains(t, closeErr.Reason(), "parallel_tool_calls to be a boolean") case <-time.After(5 * time.Second): t.Fatal("等待 ingress websocket 结束超时") } @@ -803,6 +816,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists()) + require.True(t, gjson.Get(nonLitePayload, "parallel_tool_calls").Bool()) require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw) litePayload := requestToJSONString(captureConn.writes[1]) @@ -816,6 +830,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String()) + require.True(t, gjson.Get(litePayload, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(litePayload, "parallel_tool_calls").Bool()) functionPayload := requestToJSONString(captureConn.writes[2]) require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists()) @@ -1201,6 +1217,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade "stream":false, "prompt_cache_key":"pcache_passthrough", "reasoning":{"effort":"medium","context":"current_turn"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[{"type":"message","role":"user","content":"hello"}], @@ -1236,6 +1253,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String()) + require.True(t, gjson.Get(forwarded, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(forwarded, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) { From 6a1efda0cc752925197af800b231b0b8eee1ba47 Mon Sep 17 00:00:00 2001 From: anguobao123 <205642466+anguobao123@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:02:48 +0800 Subject: [PATCH 10/64] fix(deploy): preserve gateway defaults in compose --- deploy/docker-compose.dev.yml | 9 +++++---- deploy/docker-compose.local.yml | 9 +++++---- deploy/docker-compose.standalone.yml | 9 +++++---- deploy/docker-compose.yml | 9 +++++---- deploy/tests/docker-compose-gateway-env-test.sh | 16 +++++++++++++--- 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 28c1b85166..392801e0ae 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -53,7 +53,8 @@ services: - HTTPS_PROXY=${SUB2API_DEV_HTTPS_PROXY:-http://host.docker.internal:7897} - ALL_PROXY=${SUB2API_DEV_ALL_PROXY:-socks5://host.docker.internal:7897} - NO_PROXY=${SUB2API_DEV_NO_PROXY:-127.0.0.1,localhost,::1,postgres,redis,sub2api,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,.local} - # Gateway upstream protocol, scheduling, and image configuration + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} @@ -67,9 +68,9 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} - - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} - - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} - - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index b49c646da0..0df1fb0d8b 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -161,7 +161,8 @@ services: # ======================================================================= # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} @@ -175,9 +176,9 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} - - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} - - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} - - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 3bed6b77d5..bca5e48514 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -108,7 +108,8 @@ services: # ======================================================================= # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} @@ -122,9 +123,9 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} - - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} - - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} - - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e41bc8b14f..f15b40590d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -158,7 +158,8 @@ services: # ======================================================================= # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} @@ -172,9 +173,9 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} - - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-2048} - - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-8192} - - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-4096} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} diff --git a/deploy/tests/docker-compose-gateway-env-test.sh b/deploy/tests/docker-compose-gateway-env-test.sh index 43f195feff..f068410ba2 100644 --- a/deploy/tests/docker-compose-gateway-env-test.sh +++ b/deploy/tests/docker-compose-gateway-env-test.sh @@ -25,10 +25,20 @@ for compose_file in \ do tab=$(printf '\t') while IFS="$tab" read -r key value; do + # .env.example intentionally includes high-capacity tuning examples for + # these values. An unconfigured Compose deployment must retain the + # backend defaults instead of silently adopting the examples. + case "$key" in + GATEWAY_MAX_CONNS_PER_HOST) value=1024 ;; + GATEWAY_MAX_IDLE_CONNS) value=2560 ;; + GATEWAY_MAX_IDLE_CONNS_PER_HOST) value=120 ;; + esac + expected=$(printf ' - %s=${%s:-%s}' "$key" "$key" "$value") - count=$(grep -Fxc "$expected" "$compose_file" || true) - if [ "$count" -ne 1 ]; then - printf '%s must pass %s with the documented default exactly once\n' "$compose_file" "$key" >&2 + expected_count=$(grep -Fxc "$expected" "$compose_file" || true) + key_count=$(grep -Ec "^[[:space:]]*-[[:space:]]*${key}([[:space:]]*=.*)?[[:space:]]*$" "$compose_file" || true) + if [ "$expected_count" -ne 1 ] || [ "$key_count" -ne 1 ]; then + printf '%s must pass %s with the expected fallback exactly once\n' "$compose_file" "$key" >&2 exit 1 fi done < "$gateway_variables" From 77e0409f7c35460965e20a77f6ac5f868219ae2b Mon Sep 17 00:00:00 2001 From: lyen1688 Date: Sun, 23 Aug 2026 00:30:27 +0800 Subject: [PATCH 11/64] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=AE=B5=E5=AE=9A=E4=BB=B7=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/channel_handler.go | 22 ++++++-- .../handler/admin/channel_handler_test.go | 8 ++- backend/internal/service/channel.go | 10 +++- backend/internal/service/channel_test.go | 5 +- .../service/custom_channel_time_pricing.go | 3 + .../custom_channel_time_pricing_test.go | 24 ++++++++ frontend/src/api/admin/channels.ts | 1 + .../admin/channel/TimePricingSection.vue | 55 ++++++++++++++----- .../__tests__/TimePricingSection.spec.ts | 25 ++++++++- .../admin/channel/__tests__/types.spec.ts | 16 +++++- .../src/components/admin/channel/types.ts | 5 +- .../src/i18n/locales/en/admin/channels.ts | 3 + .../src/i18n/locales/zh/admin/channels.ts | 3 + 13 files changed, 149 insertions(+), 31 deletions(-) diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index dc0e5b07e2..c1b3507aef 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -74,8 +74,9 @@ type channelModelPricingRequest struct { } type channelTimePricingRequest struct { - Timezone string `json:"timezone"` - Periods []channelTimePricingPeriodRequest `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodRequest `json:"periods"` } type channelTimePricingPeriodRequest struct { @@ -144,8 +145,9 @@ type channelModelPricingResponse struct { } type channelTimePricingResponse struct { - Timezone string `json:"timezone"` - Periods []channelTimePricingPeriodResponse `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodResponse `json:"periods"` } type channelTimePricingPeriodResponse struct { @@ -282,7 +284,11 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin Multiplier: period.Multiplier, }) } - return &channelTimePricingResponse{Timezone: value.Timezone, Periods: periods} + return &channelTimePricingResponse{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, + } } func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse { @@ -374,7 +380,11 @@ func timePricingRequestToService(value *channelTimePricingRequest) *service.Chan Multiplier: period.Multiplier, }) } - return &service.ChannelTimePricing{Timezone: value.Timezone, Periods: periods} + return &service.ChannelTimePricing{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, + } } func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule { diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index 75aec7db31..2da7e02d00 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -426,7 +426,8 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { Models: []string{"gpt-5"}, BillingMode: "token", TimePricing: &channelTimePricingRequest{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []channelTimePricingPeriodRequest{{ StartTime: "09:00", EndTime: "12:00", Multiplier: 2, }}, @@ -435,6 +436,7 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { got := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone) + require.True(t, got[0].TimePricing.WeekdaysOnly) require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier) } @@ -483,7 +485,8 @@ func TestPricingToResponse_TimePricing(t *testing.T) { got := pricingToResponse(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, TimePricing: &service.ChannelTimePricing{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []service.ChannelTimePricingPeriod{{ StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25, }}, @@ -492,6 +495,7 @@ func TestPricingToResponse_TimePricing(t *testing.T) { require.NotNil(t, got.TimePricing) require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone) + require.True(t, got.TimePricing.WeekdaysOnly) require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier) } diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 0fe4e3b07c..3e300edf99 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -109,8 +109,9 @@ type ChannelModelPricing struct { // ChannelTimePricing 渠道模型定价的分时倍率配置。 type ChannelTimePricing struct { - Timezone string `json:"timezone"` - Periods []ChannelTimePricingPeriod `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []ChannelTimePricingPeriod `json:"periods"` } // ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。 @@ -216,7 +217,10 @@ func (p ChannelModelPricing) Clone() ChannelModelPricing { copy(cp.Intervals, p.Intervals) } if p.TimePricing != nil { - cp.TimePricing = &ChannelTimePricing{Timezone: p.TimePricing.Timezone} + cp.TimePricing = &ChannelTimePricing{ + Timezone: p.TimePricing.Timezone, + WeekdaysOnly: p.TimePricing.WeekdaysOnly, + } if p.TimePricing.Periods != nil { cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...) } diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 19e45a02db..42c2c46448 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -198,7 +198,8 @@ func TestChannelModelPricingClone(t *testing.T) { {MinTokens: 0, TierLabel: "tier1"}, }, TimePricing: &ChannelTimePricing{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{{ StartTime: "09:00", EndTime: "12:00", @@ -217,9 +218,11 @@ func TestChannelModelPricingClone(t *testing.T) { require.Equal(t, "tier1", original.Intervals[0].TierLabel) cloned.TimePricing.Timezone = "America/New_York" + cloned.TimePricing.WeekdaysOnly = false cloned.TimePricing.Periods[0].StartTime = "10:00" cloned.TimePricing.Periods[0].Multiplier = 3 require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone) + require.True(t, original.TimePricing.WeekdaysOnly) require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime) require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier) } diff --git a/backend/internal/service/custom_channel_time_pricing.go b/backend/internal/service/custom_channel_time_pricing.go index 759eac987e..5b7ce8eb3d 100644 --- a/backend/internal/service/custom_channel_time_pricing.go +++ b/backend/internal/service/custom_channel_time_pricing.go @@ -47,6 +47,9 @@ func (config *ChannelTimePricing) MultiplierAt(at time.Time) float64 { } local := at.In(location) + if config.WeekdaysOnly && (local.Weekday() == time.Saturday || local.Weekday() == time.Sunday) { + return 1.0 + } second := local.Hour()*60*60 + local.Minute()*60 + local.Second() for _, period := range periods { if second >= period.start && second < period.end { diff --git a/backend/internal/service/custom_channel_time_pricing_test.go b/backend/internal/service/custom_channel_time_pricing_test.go index f1038af0d3..f36c19b7c9 100644 --- a/backend/internal/service/custom_channel_time_pricing_test.go +++ b/backend/internal/service/custom_channel_time_pricing_test.go @@ -95,6 +95,30 @@ func TestChannelTimePricingMultiplierAt(t *testing.T) { require.Equal(t, 2.0, newYork.MultiplierAt(at)) } +func TestChannelTimePricingMultiplierAtWeekdaysOnly(t *testing.T) { + config := &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: onePeriod(), + } + + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "Monday in configured timezone", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2}, + {name: "Saturday in configured timezone", at: time.Date(2026, 7, 4, 1, 0, 0, 0, time.UTC), want: 1}, + {name: "Sunday in configured timezone", at: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) { config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2}) shanghai, err := time.LoadLocation("Asia/Shanghai") diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 9ce50bcd0a..94967e3692 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -33,6 +33,7 @@ export interface ChannelTimePricingPeriod { export interface ChannelTimePricing { timezone: string + weekdays_only?: boolean periods: ChannelTimePricingPeriod[] } diff --git a/frontend/src/components/admin/channel/TimePricingSection.vue b/frontend/src/components/admin/channel/TimePricingSection.vue index b2e31ca46d..ae329f3680 100644 --- a/frontend/src/components/admin/channel/TimePricingSection.vue +++ b/frontend/src/components/admin/channel/TimePricingSection.vue @@ -1,22 +1,40 @@ - + - + @@ -377,6 +379,13 @@ const requestDetailsPreset = ref({ sort: 'created_at_desc' }) +// 记录单条错误详情来自哪个列表,便于"返回列表"时重新打开对应弹窗并保留状态。 +type DetailReturnTarget = 'errorList' | 'requestList' | null +const detailReturnTarget = ref(null) + +// 从详情返回时,列表弹窗应保留上一次的筛选/分页状态而非重置。 +const resumeListState = ref(false) + const showSettingsDialog = ref(false) const showAlertRulesCard = ref(false) @@ -508,12 +517,34 @@ function onQueryModeChange(v: string | number | boolean | null) { function openError(id: number) { selectedErrorId.value = id + // 记录来源列表,便于详情页"返回列表"。 + detailReturnTarget.value = showRequestDetails.value ? 'requestList' : showErrorDetails.value ? 'errorList' : null // Ensure only one modal visible at a time. showErrorDetails.value = false showRequestDetails.value = false showErrorModal.value = true } +// 从单条错误详情返回其来源列表,重新打开关联弹窗(保留筛选/分页状态)。 +function handleBackToList() { + const target = detailReturnTarget.value + resumeListState.value = true + if (target === 'requestList') { + showErrorModal.value = false + showErrorDetails.value = false + showRequestDetails.value = true + } else if (target === 'errorList') { + showErrorModal.value = false + showRequestDetails.value = false + showErrorDetails.value = true + } + detailReturnTarget.value = null + // 子组件 watch 在本次 show 变化中消费 resumeState 后复位,保证下次手动打开仍会重置筛选。 + window.setTimeout(() => { + resumeListState.value = false + }, 0) +} + function buildApiParams() { const params: any = { platform: platform.value || undefined, diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index 407c671ca9..8b0dc388f8 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -211,6 +211,16 @@
+ @@ -228,10 +238,12 @@ interface Props { show: boolean errorId: number | null errorType?: 'request' | 'upstream' + backToList?: boolean } interface Emits { (e: 'update:show', value: boolean): void + (e: 'back'): void } const props = defineProps() @@ -363,6 +375,11 @@ function close() { emit('update:show', false) } +function goBack() { + emit('update:show', false) + emit('back') +} + function prettyJSON(raw?: string): string { if (!raw) return 'N/A' try { diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue index 0c513a5f1d..e97e919f6a 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue @@ -15,6 +15,7 @@ interface Props { platform?: string groupId?: number | null errorType: 'request' | 'upstream' + resumeState?: boolean } const props = defineProps() @@ -167,6 +168,7 @@ watch( () => props.show, (open) => { if (!open) return + if (props.resumeState) return page.value = 1 pageSize.value = 10 resetFilters() diff --git a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue index 33ce058149..2d8e885a0c 100644 --- a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue +++ b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue @@ -23,6 +23,7 @@ interface Props { preset: OpsRequestDetailsPreset platform?: string groupId?: number | null + resumeState?: boolean } const props = defineProps() @@ -98,6 +99,7 @@ watch( () => props.modelValue, (open) => { if (open) { + if (props.resumeState) return page.value = 1 pageSize.value = 10 fetchData() @@ -142,7 +144,6 @@ async function handleCopyRequestId(requestId: string) { function openErrorDetail(errorId: number | null | undefined) { if (!errorId) return - close() emit('openErrorDetail', errorId) } From f06bf181d205682ad341ca42b1046193068c997a Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 24 Aug 2026 10:08:28 +0800 Subject: [PATCH 37/64] feat(openai): support Fast mode service_tier across responses/chat/WS paths - Accept fast|priority (canonical priority), flex|auto|default|scale on /v1/responses and /v1/chat/completions; reject unknown/empty/non-string with HTTP 400; omitted and null stay compatible. - Propagate service_tier through JSON/SSE, Responses<->Chat conversions, fallback paths and HTTP->upstream WebSocket bridge. - Billing prefers the upstream terminal tier; the outbound (policy- transformed) tier is used only when upstream omits the field. Explicit upstream default bills Standard even when Fast was requested. - Pricing: Fast premium 2x Standard for gpt-5.6-sol/terra/luna and gpt-5.4; 2.5x for gpt-5.5; channel FastMultiplier stays authoritative. - Live verification (official Codex 0.149.0 + gateway, HTTP & WS): upstream ChatGPT backend may return terminal default even when the account catalog advertises priority; billing follows the actual tier. --- .../handler/openai_chat_completions.go | 4 + .../handler/openai_gateway_handler.go | 4 + .../openai_service_tier_validation_test.go | 127 +++ .../chatcompletions_responses_bridge.go | 32 +- .../apicompat/responses_to_chatcompletions.go | 60 +- .../service_tier_passthrough_test.go | 175 ++++ backend/internal/pkg/apicompat/types.go | 13 +- backend/internal/service/billing_service.go | 39 +- .../service/openai_fast_service_tier_test.go | 834 ++++++++++++++++++ .../service/openai_gateway_cc_pipeline.go | 9 + .../openai_gateway_chat_completions.go | 14 +- .../openai_gateway_chat_completions_raw.go | 10 +- .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_messages.go | 8 +- .../openai_gateway_messages_chat_fallback.go | 8 +- .../service/openai_gateway_passthrough.go | 2 +- .../service/openai_gateway_request_body.go | 51 ++ .../openai_gateway_responses_chat_fallback.go | 18 +- .../service/openai_gateway_service.go | 6 +- .../service/openai_ws_forwarder_v2.go | 2 +- .../service/openai_ws_forwarder_v2_test.go | 96 ++ .../internal/service/openai_ws_http_bridge.go | 2 +- .../service/openai_ws_http_bridge_test.go | 39 + .../service/upstream_response_model.go | 33 +- .../model_prices_and_context_window.json | 12 +- 25 files changed, 1518 insertions(+), 82 deletions(-) create mode 100644 backend/internal/handler/openai_service_tier_validation_test.go create mode 100644 backend/internal/pkg/apicompat/service_tier_passthrough_test.go create mode 100644 backend/internal/service/openai_fast_service_tier_test.go create mode 100644 backend/internal/service/openai_ws_forwarder_v2_test.go diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 7f83f9ba90..aa2b610d70 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -89,6 +89,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } if service.IsGPTImageGenerationModel(reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint") return diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index d9da0bef71..32cea2f073 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -392,6 +392,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) previousResponseID := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) if previousResponseID != "" { diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go new file mode 100644 index 0000000000..b912f812f8 --- /dev/null +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -0,0 +1,127 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 非法 service_tier 必须在两个 OpenAI 端点(/v1/responses、/v1/chat/completions) +// 上以 OpenAI 兼容错误结构返回 HTTP 400;合法值(fast/priority)不被拒绝。 + +func newServiceTierHandlerTest(t *testing.T) *OpenAIGatewayHandler { + t.Helper() + return &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil), + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService( + &helperConcurrencyCacheStub{userSeq: []bool{true}}, + )}, + cfg: &config.Config{}, + imageLimiter: &imageConcurrencyLimiter{}, + } +} + +func runOpenAIHandlerServiceTierTest(t *testing.T, path, body string, handler func(h *OpenAIGatewayHandler, c *gin.Context)) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(6401) + userID := int64(6402) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 6403, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, + User: &service.User{ID: userID, Status: service.StatusActive}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1}) + + handler(newServiceTierHandlerTest(t), c) + return rec +} + +func TestOpenAIGatewayHandlerResponses_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","input":"hi","service_tier":"turbo"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":"SPEED"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":""}`, + `{"model":"gpt-5.5","input":"hi","service_tier":123}`, + `{"model":"gpt-5.5","input":"hi","service_tier":{}}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.Responses(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} + +func TestOpenAIGatewayHandlerResponses_ValidServiceTierNotRejected(t *testing.T) { + for _, tier := range []string{"fast", "priority", "flex"} { + body := `{"model":"gpt-5.5","input":"hi","service_tier":"` + tier + `"}` + rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.Responses(c) + }) + require.NotEqual(t, http.StatusBadRequest, rec.Code, "tier=%s must not be rejected as invalid", tier) + require.NotContains(t, rec.Body.String(), "invalid service_tier", "tier=%s", tier) + } +} + +func TestOpenAIGatewayHandlerResponses_ServiceTierOmittedKeepsCurrentBehavior(t *testing.T) { + body := `{"model":"gpt-5.5","input":"hi"}` + rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.Responses(c) + }) + require.NotContains(t, rec.Body.String(), "invalid service_tier") +} + +func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"turbo"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"ultra"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":""}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":["priority"]}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.ChatCompletions(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} + +func TestOpenAIGatewayHandlerChatCompletions_ValidServiceTierNotRejected(t *testing.T) { + for _, tier := range []string{"fast", "priority", "auto", "default", "scale", "flex"} { + body := `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"` + tier + `"}` + rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.ChatCompletions(c) + }) + require.NotEqual(t, http.StatusBadRequest, rec.Code, "tier=%q must not be rejected as invalid", tier) + require.NotContains(t, rec.Body.String(), "invalid service_tier", "tier=%q", tier) + } +} + +func TestOpenAIGatewayHandlerChatCompletions_ServiceTierOmittedKeepsCurrentBehavior(t *testing.T) { + body := `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}` + rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.ChatCompletions(c) + }) + require.NotContains(t, rec.Body.String(), "invalid service_tier") +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index de2b803012..fd84af7824 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -1212,10 +1212,11 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str } out := &ResponsesResponse{ - ID: id, - Object: "response", - Model: model, - Status: "completed", + ID: id, + Object: "response", + Model: model, + Status: "completed", + ServiceTier: chatServiceTier(resp), } if resp == nil { out.Output = []ResponsesOutput{emptyResponsesMessageOutput()} @@ -1242,6 +1243,13 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } +func chatServiceTier(resp *ChatCompletionsResponse) string { + if resp == nil { + return "" + } + return resp.ServiceTier +} + func chatMessageToResponsesOutput(message ChatMessage, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput reasoning := message.reasoningText() @@ -1414,6 +1422,7 @@ type ChatCompletionsToResponsesStreamState struct { ResponseID string Model string Created int64 + ServiceTier string // upstream Chat chunk service_tier, echoed on response events SequenceNumber int CreatedSent bool CompletedSent bool @@ -1545,6 +1554,9 @@ func ChatCompletionsChunkToResponsesEvents( if state.Model == "" && chunk.Model != "" { state.Model = chunk.Model } + if chunk.ServiceTier != "" { + state.ServiceTier = chunk.ServiceTier + } if chunk.Usage != nil { state.Usage = ChatUsageToResponsesUsage(chunk.Usage) } @@ -1701,6 +1713,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr Object: "response", Model: state.Model, Status: status, + ServiceTier: state.ServiceTier, Output: state.chatOutput(), Usage: state.Usage, IncompleteDetails: incompleteDetails, @@ -1716,11 +1729,12 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState) state.CreatedSent = true return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{ Response: &ResponsesResponse{ - ID: state.ResponseID, - Object: "response", - Model: state.Model, - Status: "in_progress", - Output: []ResponsesOutput{}, + ID: state.ResponseID, + Object: "response", + Model: state.Model, + Status: "in_progress", + ServiceTier: state.ServiceTier, + Output: []ResponsesOutput{}, }, })} } diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index a89a1b4203..d288b31bea 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -23,10 +23,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } out := &ChatCompletionsResponse{ - ID: id, - Object: "chat.completion", - Created: time.Now().Unix(), - Model: model, + ID: id, + Object: "chat.completion", + Created: time.Now().Unix(), + Model: model, + ServiceTier: resp.ServiceTier, } var contentText string @@ -118,6 +119,7 @@ type ResponsesEventToChatState struct { ID string Model string Created int64 + ServiceTier string // upstream tier observed on response events; echoed on chunks SentRole bool SawToolCall bool SawText bool @@ -187,12 +189,13 @@ func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatComplet if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -218,6 +221,9 @@ func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToCh if state.Model == "" && evt.Response.Model != "" { state.Model = evt.Response.Model } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } } // Emit the role chunk. if state.SentRole { @@ -301,6 +307,9 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if evt.Response.Usage != nil { state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage) } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } switch evt.Response.Status { case "incomplete": @@ -326,12 +335,13 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -401,10 +411,11 @@ func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatToke func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk { return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: delta, @@ -416,10 +427,11 @@ func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatC func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk { empty := "" return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: ChatDelta{Content: &empty}, diff --git a/backend/internal/pkg/apicompat/service_tier_passthrough_test.go b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go new file mode 100644 index 0000000000..58d3c83e07 --- /dev/null +++ b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go @@ -0,0 +1,175 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// 上游响应中的 service_tier 必须如实回传,不被重写/丢弃: +// 非流式(ResponsesResponse → ChatCompletionsResponse)与流式 chunk 均覆盖。 + +func TestResponsesToChatCompletions_PreservesUpstreamServiceTier(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_1", + Object: "response", + Model: "gpt-5.5", + Status: "completed", + ServiceTier: "priority", + Output: []ResponsesOutput{{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{{ + Type: "output_text", + Text: "hi", + }}, + }}, + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + } + + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Equal(t, "priority", chat.ServiceTier) + + // 序列化后字段仍在(omitempty 不丢非空值)。 + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"priority"`) +} + +func TestResponsesToChatCompletions_OmitsMissingServiceTier(t *testing.T) { + resp := &ResponsesResponse{ID: "resp_1", Model: "gpt-5.5", Status: "completed"} + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Empty(t, chat.ServiceTier) + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func TestResponsesEventToChatChunks_PreservesUpstreamServiceTier(t *testing.T) { + state := NewResponsesEventToChatState() + state.IncludeUsage = true + + created := &ResponsesStreamEvent{Type: "response.created"} + require.NoError(t, json.Unmarshal([]byte(`{"type":"response.created","response":{"id":"resp_s1","model":"gpt-5.5","service_tier":"priority","status":"in_progress"}}`), created)) + + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } + + // 后续 delta chunk 继续携带(OpenAI 流式 chunk 的 service_tier 语义)。 + delta := &ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "hi"} + chunks = ResponsesEventToChatChunks(delta, state) + require.NotEmpty(t, chunks) + require.Equal(t, "priority", chunks[0].ServiceTier) + + // 终止事件同样携带。 + completed := &ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{ + ID: "resp", Model: "gpt-5.5", Status: "completed", + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + }} + chunks = ResponsesEventToChatChunks(completed, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } +} + +func TestResponsesEventToChatChunks_NoServiceTierStaysClean(t *testing.T) { + state := NewResponsesEventToChatState() + created := &ResponsesStreamEvent{Type: "response.created", Response: &ResponsesResponse{ID: "resp", Model: "gpt-5.5"}} + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + require.Empty(t, chunks[0].ServiceTier) + raw, err := json.Marshal(chunks[0]) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +// 上游 JSON 反序列化时 service_tier 进入 ResponsesResponse(缓冲桥读取链路)。 +func TestResponsesResponse_UnmarshalPreservesServiceTier(t *testing.T) { + var resp ResponsesResponse + require.NoError(t, json.Unmarshal([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.5","status":"completed","service_tier":"flex","output":[]}`), &resp)) + require.Equal(t, "flex", resp.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 反向转换(Chat-only fallback):CC 响应/流 chunk 的 service_tier 保留到 +// Responses 形态,客户端与计费都能拿到上游回显。 +// --------------------------------------------------------------------------- + +func TestChatCompletionsResponseToResponses_PreservesServiceTier(t *testing.T) { + cc := &ChatCompletionsResponse{ + ID: "chatcmpl-1", + Model: "gpt-5.5", + ServiceTier: "default", + Choices: []ChatChoice{{ + Index: 0, + Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, + FinishReason: "stop", + }}, + Usage: &ChatUsage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + } + resp := ChatCompletionsResponseToResponses(cc, "gpt-5.5", nil, false, nil) + require.Equal(t, "default", resp.ServiceTier) + + raw, err := json.Marshal(resp) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"default"`) +} + +func TestChatCompletionsResponseToResponses_NilRespOmitsServiceTier(t *testing.T) { + resp := ChatCompletionsResponseToResponses(nil, "gpt-5.5", nil, false, nil) + require.Empty(t, resp.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_PreservesServiceTier(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ + ID: "chatcmpl-2", + Model: "gpt-5.5", + ServiceTier: "flex", + Choices: []ChatChunkChoice{{ + Index: 0, + Delta: ChatDelta{Content: strPtr("hi")}, + }}, + } + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + require.NotEmpty(t, events) + + // response.created 携带 service_tier。 + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.NotNil(t, created.Response) + require.Equal(t, "flex", created.Response.ServiceTier) + + // 终止事件同样携带。 + final := FinalizeChatCompletionsResponsesStream(state) + completed := findEvent(final, "response.completed") + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.Equal(t, "flex", completed.Response.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_NoTierStaysClean(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ID: "chatcmpl-3", Model: "gpt-5.5"} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.Empty(t, created.Response.ServiceTier) + raw, err := json.Marshal(created) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func findEvent(events []ResponsesStreamEvent, eventType string) *ResponsesStreamEvent { + for i := range events { + if events[i].Type == eventType { + return &events[i] + } + } + return nil +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b96d16b911..af56263033 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -344,12 +344,13 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error { // ResponsesResponse is the non-streaming response from POST /v1/responses. type ResponsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` // "response" - Model string `json:"model"` - Status string `json:"status"` // "completed" | "incomplete" | "failed" - Output []ResponsesOutput `json:"output"` - Usage *ResponsesUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` // "response" + Model string `json:"model"` + Status string `json:"status"` // "completed" | "incomplete" | "failed" + Output []ResponsesOutput `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` // upstream tier, echoed back verbatim // incomplete_details is present when status="incomplete" IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 6345f2c40e..42dd06c03c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1475,7 +1475,8 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) - if !needsLongContextPolicy && !needsCacheCreationPolicy { + fastRatio := openAIModelFastPricingRatio(normalized) + if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing @@ -1498,9 +1499,45 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier } } + if fastRatio > 0 { + enforceOpenAIFastPricingRatio(&cloned, fastRatio) + } return &cloned } +// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority +// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast +// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 +func openAIModelFastPricingRatio(normalized string) float64 { + switch normalized { + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna": + return 2.0 + case "gpt-5.5": + return 2.5 + default: + return 0 + } +} + +// enforceOpenAIFastPricingRatio 把 priority 档价格改写为「标准价 × ratio」。 +// 本地/远程 LiteLLM 目录可能只带官方旧口径(如 gpt-5.5 priority 仍标 2x), +// 直接采用会导致 Fast 模式少计费;这里按业务倍率兜底修正,且对已正确的 +// fallback 条目(2x/2.5x)是幂等的。computeTokenBreakdown 在 priority 价格 +// 存在时走显式档位价、不再叠加通用 tier 倍率,因此不会重复乘价。 +func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) { + if pricing == nil || ratio <= 0 { + return + } + pricing.InputPricePerTokenPriority = pricing.InputPricePerToken * ratio + pricing.OutputPricePerTokenPriority = pricing.OutputPricePerToken * ratio + if pricing.CacheReadPricePerToken > 0 { + pricing.CacheReadPricePerTokenPriority = pricing.CacheReadPricePerToken * ratio + } + if pricing.CacheCreationPricePerToken > 0 { + pricing.CacheCreationPricePerTokenPriority = pricing.CacheCreationPricePerToken * ratio + } +} + func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool { if pricing == nil || pricing.LongContextInputThreshold <= 0 { return false diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go new file mode 100644 index 0000000000..31b500c8ba --- /dev/null +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -0,0 +1,834 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// --------------------------------------------------------------------------- +// 请求侧:service_tier 校验(fast/priority 等价、非法值拒绝、省略保持现状) +// --------------------------------------------------------------------------- + +func TestValidateOpenAIServiceTierField(t *testing.T) { + t.Parallel() + + t.Run("fast normalizes to priority", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"fast"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("priority passes through", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"priority"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("case and whitespace insensitive", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":" FAST "}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("official tiers pass through", func(t *testing.T) { + for _, tier := range []string{"flex", "auto", "default", "scale"} { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`)) + require.NoError(t, err, "tier %q must be accepted", tier) + require.Equal(t, tier, norm) + } + }) + + t.Run("invalid tier rejected", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"turbo"}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "turbo", invalid.Value) + require.Contains(t, err.Error(), "invalid service_tier") + require.Contains(t, err.Error(), "fast", "allowed-value hint must mention fast") + }) + + t.Run("omitted field stays valid", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","input":"hi"}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("null value keeps omission semantics", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":null}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("explicit empty string rejected as invalid enum value", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":""}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + }) + + t.Run("non-string service_tier rejected", func(t *testing.T) { + // service_tier 必须为字符串;数字/布尔/对象/数组等类型同样按非法值拒绝。 + for _, raw := range []string{ + `{"model":"gpt-5.5","service_tier":123}`, + `{"model":"gpt-5.5","service_tier":true}`, + `{"model":"gpt-5.5","service_tier":{}}`, + `{"model":"gpt-5.5","service_tier":["priority"]}`, + } { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err, "raw=%s must be rejected", raw) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid), "raw=%s", raw) + require.Equal(t, "", invalid.Value, "raw=%s", raw) + require.Contains(t, err.Error(), "invalid service_tier") + } + }) + + t.Run("oversized unknown string is truncated", func(t *testing.T) { + blob := strings.Repeat("z", 4096) + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + blob + `"}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, strings.Repeat("z", 64)+"...", invalid.Value) + }) + + t.Run("non-string large object/array is not echoed", func(t *testing.T) { + blob := strings.Repeat("x", 4096) + payloads := []string{ + `{"model":"gpt-5.5","service_tier":{"blob":"` + blob + `"}}`, + `{"model":"gpt-5.5","service_tier":["` + blob + `"]}`, + } + for _, raw := range payloads { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "", invalid.Value) + } + }) +} + +// --------------------------------------------------------------------------- +// 计费:gpt-5.6 系列 / gpt-5.4 按标准价 2x,gpt-5.5 按标准价 2.5x +// --------------------------------------------------------------------------- + +func TestApplyModelSpecificPricingPolicy_EnforcesOpenAIFastRatios(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + + t.Run("gpt-5.5 catalog 2x priority is corrected to 2.5x", func(t *testing.T) { + // 模拟本地 LiteLLM 目录仍携带官方旧口径(gpt-5.5 priority = 2x)。 + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + // 标准价不被改动。 + require.InDelta(t, 5e-6, got.InputPricePerToken, 1e-12) + // 原始指针不被污染。 + require.InDelta(t, 10e-6, catalog.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.4 keeps 2x", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.4", &ModelPricing{ + InputPricePerToken: 2.5e-6, + InputPricePerTokenPriority: 5e-6, + OutputPricePerToken: 15e-6, + OutputPricePerTokenPriority: 30e-6, + }) + require.InDelta(t, 5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, got.OutputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.6 family keeps 2x", func(t *testing.T) { + for _, model := range []string{"gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-max", "gpt-5.6-sol-preview"} { + got := svc.applyModelSpecificPricingPolicy(model, &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + }) + require.InDelta(t, 10e-6, got.InputPricePerTokenPriority, 1e-12, "model %s", model) + require.InDelta(t, 60e-6, got.OutputPricePerTokenPriority, 1e-12, "model %s", model) + } + }) + + t.Run("missing priority prices are backfilled from standard", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheReadPricePerToken: 0.5e-6, + CacheCreationPricePerToken: 5e-6, + }) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + require.InDelta(t, 12.5e-6, got.CacheCreationPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.5-pro has no mandated fast tier", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5-pro", &ModelPricing{ + InputPricePerToken: 30e-6, + InputPricePerTokenPriority: 60e-6, + OutputPricePerToken: 180e-6, + }) + require.InDelta(t, 60e-6, got.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("unrelated models untouched", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("claude-opus-5", &ModelPricing{InputPricePerToken: 1, OutputPricePerToken: 2}) + require.InDelta(t, 1, got.InputPricePerToken, 1e-12) + require.Zero(t, got.InputPricePerTokenPriority) + }) +} + +func TestOpenAIFastBillingMultiplier_2xAnd25x(t *testing.T) { + t.Parallel() + + // 目录数据携带官方旧口径(gpt-5.5 priority=2x);修正后 fast 必须按 2.5x 计费。 + catalog := map[string]*LiteLLMModelPricing{ + "gpt-5.4": { + InputCostPerToken: 2.5e-6, + InputCostPerTokenPriority: 5e-6, + OutputCostPerToken: 15e-6, + OutputCostPerTokenPriority: 30e-6, + CacheReadInputTokenCost: 0.25e-6, + CacheReadInputTokenCostPriority: 0.5e-6, + }, + "gpt-5.5": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + "gpt-5.6-sol": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + } + billing := NewBillingService(&config.Config{}, &PricingService{pricingData: catalog}) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000} + + standard := func(model string) *CostBreakdown { + cost, err := billing.CalculateCost(model, tokens, 1) + require.NoError(t, err) + return cost + } + fast := func(model, tier string) *CostBreakdown { + cost, err := billing.CalculateCostWithServiceTier(model, tokens, 1, tier) + require.NoError(t, err) + return cost + } + + tests := []struct { + model string + ratio float64 + }{ + {model: "gpt-5.4", ratio: 2.0}, + {model: "gpt-5.5", ratio: 2.5}, + {model: "gpt-5.6-sol", ratio: 2.0}, + {model: "gpt-5.6-terra", ratio: 2.0}, + {model: "gpt-5.6-luna", ratio: 2.0}, + } + for _, tt := range tests { + t.Run(tt.model+"/fast", func(t *testing.T) { + base := standard(tt.model) + fastCost := fast(tt.model, "fast") + require.InDelta(t, base.TotalCost*tt.ratio, fastCost.TotalCost, 1e-9, + "fast total must be %.1fx standard", tt.ratio) + }) + t.Run(tt.model+"/priority_alias", func(t *testing.T) { + fastCost := fast(tt.model, "fast") + priorityCost := fast(tt.model, "priority") + require.InDelta(t, fastCost.TotalCost, priorityCost.TotalCost, 1e-12, + "client alias fast must bill identically to priority") + require.InDelta(t, standard(tt.model).TotalCost*tt.ratio, priorityCost.TotalCost, 1e-9) + }) + t.Run(tt.model+"/no_tier_unchanged", func(t *testing.T) { + base := standard(tt.model) + noTier, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, noTier.TotalCost, 1e-12) + }) + t.Run(tt.model+"/default_equals_standard", func(t *testing.T) { + base := standard(tt.model) + defaultCost, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "default") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, defaultCost.TotalCost, 1e-12) + require.InDelta(t, base.InputCost, defaultCost.InputCost, 1e-12) + require.InDelta(t, base.OutputCost, defaultCost.OutputCost, 1e-12) + require.InDelta(t, base.CacheReadCost, defaultCost.CacheReadCost, 1e-12) + }) + } +} + +func TestOpenAIFastBilling_FastMultiplierOverridesEnforcedRatio(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + pricing := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12, "enforce must still write 2.5x priority prices") + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) + + multiplier := 1.7 + pricing.FastMultiplier = &multiplier + + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + standard := svc.computeTokenBreakdown(pricing, tokens, 1, "", false) + fast := svc.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + priority := svc.computeTokenBreakdown(pricing, tokens, 1, "priority", false) + + require.InDelta(t, standard.TotalCost*1.7, fast.TotalCost, 1e-9) + require.InDelta(t, fast.TotalCost, priority.TotalCost, 1e-12) + + withoutOverride := *pricing + withoutOverride.FastMultiplier = nil + enforced := svc.computeTokenBreakdown(&withoutOverride, tokens, 1, "fast", false) + require.InDelta(t, standard.TotalCost*2.5, enforced.TotalCost, 1e-9, + "without FastMultiplier the same enforced prices still bill 2.5x") +} + +// --------------------------------------------------------------------------- +// 上游 payload:fast 归一化为 priority 并确实到达上游 +// --------------------------------------------------------------------------- + +func TestForwardAsChatCompletions_ServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) // upstream 400 → 错误返回,但请求体已被 recorder 捕获 + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach upstream as priority") +} + +func TestForwardAsChatCompletions_ServiceTierPriorityPreservedUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"priority","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st2"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 2, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) +} + +func TestForward_ResponsesServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach the upstream as priority") + // 计费上下文:result 携带归一化后的 tier。 + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier) +} + +func TestForward_ResponsesServiceTierOmittedStaysOmitted(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st2"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_2","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "omitted service_tier must stay omitted") + require.Nil(t, result.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 流式计费上下文:service_tier 需要从请求体传到 usage 计费 +// --------------------------------------------------------------------------- + +func TestForwardStreaming_ServiceTierPropagatedToResult(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-stream-st"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier, "streaming billing context must carry the normalized tier") + // /v1/responses 流是上游 SSE 原样透传:上游没回 service_tier 就不该出现; + // 网关只在计费结果里携带请求侧 tier,不往下游流里注入。 + require.Contains(t, rec.Body.String(), `"delta":"hi"`, "streamed content must reach the client") + require.NotContains(t, rec.Body.String(), `"service_tier"`, "upstream did not return service_tier, client stream must stay untouched") +} + +// --------------------------------------------------------------------------- +// 上游回显优先:请求 fast 但上游真实返回 default → 计费按标准价 +// --------------------------------------------------------------------------- + +func TestForward_ResponsesUpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 上游回显 service_tier=default(例如请求实际被降级)。 + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-echo"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "upstream-echoed default must override the client-requested fast tier for billing") + // 非流式响应原样透传:客户端同样看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +func TestForwardStreaming_UpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-echo-stream"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "terminal SSE event's upstream-echoed default must win for billing") + // 流式原样透传:客户端在终止事件里看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) +} + +func TestForwardAsChatCompletions_UpstreamEchoesDefault_BillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-chat-echo"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "CC bridge must bill on the upstream-echoed default, not the requested fast") + // 缓冲转回 Chat Completions:客户端响应里如实回显 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +// --------------------------------------------------------------------------- +// policy filter:删除 service_tier 后不得再按原请求 Fast 计费 +// --------------------------------------------------------------------------- + +func TestForward_ServiceTierFilteredByPolicyBillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 管理员配置 priority → filter:字段在出站前被删除。 + settings := &OpenAIFastPolicySettings{Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }}} + raw, err := json.Marshal(settings) + require.NoError(t, err) + repo := &openAIFastPolicyRepoStub{values: map[string]string{SettingKeyOpenAIFastPolicySettings: string(raw)}} + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-filter"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(repo, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + // 出站 body 已剥离 service_tier、上游也未回显 → 无 tier → 按标准价计费。 + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "policy filter must strip service_tier from the outbound body") + require.Nil(t, result.ServiceTier, "filtered request must not bill as fast") +} + +// --------------------------------------------------------------------------- +// 上游回显观察与解析器单测 +// --------------------------------------------------------------------------- + +func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) { + t.Parallel() + + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created") + require.Equal(t, "flex", observer.ServiceTier()) + + // terminal 声明优先。 + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + require.Equal(t, "default", observer.ServiceTier()) + + // Chat Completions 顶层 service_tier 同样可观察。 + ccObserver := &upstreamResponseModelObserver{} + ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "chat.completion") + require.Equal(t, "priority", ccObserver.ServiceTier()) +} + +func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { + t.Parallel() + + priority := func() *string { v := "priority"; return &v }() + + t.Run("upstream echo wins over outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("no upstream echo falls back to outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + beginUpstreamResponseModelObservation(c) + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("upstream alias fast normalizes to priority", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"fast"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, nil) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer keeps outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTier(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer and no outbound tier stays nil", func(t *testing.T) { + require.Nil(t, resolvedOpenAIUpstreamServiceTier(nil, nil)) + }) + + t.Run("local observer wins without gin context", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("nil local observer falls back to outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTierFromObserver(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) +} diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 5115dcfea7..8d45c4ff93 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -249,6 +249,7 @@ type ccStreamScanState struct { // emit 回调做各自的协议转换与写出。读错误按既有约定过滤 context 取消类噪声后 // 记入 Warn 日志。 func (s *OpenAIGatewayService) scanCCStream( + c *gin.Context, resp *http.Response, logPrefix string, requestID string, @@ -272,6 +273,10 @@ func (s *OpenAIGatewayService) scanCCStream( st.SawDone = true break } + // 观察上游 CC chunk 回显的 model / service_tier(计费以回显为准)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI([]byte(payload), "chat.completion.chunk") + } if u := extractCCStreamUsage(payload); u != nil { st.Usage = *u @@ -331,6 +336,10 @@ func (s *OpenAIGatewayService) readCCUpstreamJSONResponse( writeError(c, http.StatusBadGateway, "api_error", "Failed to parse upstream response") return nil, OpenAIUsage{}, fmt.Errorf("parse chat completions response: %w", err) } + // 观察上游 CC JSON 回显的 model / service_tier(计费以回显为准)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI(respBody, "chat.completion") + } usage := OpenAIUsage{} if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index d5639fbe31..7d9241a7ca 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -190,7 +190,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( responsesBody = stripped } } - responsesBody, normalizedServiceTier, err := normalizeResponsesBodyServiceTier(responsesBody) + var normalizedServiceTier string + responsesBody, normalizedServiceTier, err = normalizeResponsesBodyServiceTier(responsesBody) if err != nil { return nil, fmt.Errorf("normalize service_tier in responses-shape body: %w", err) } @@ -371,11 +372,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, handleErr } - // Propagate ServiceTier and ReasoningEffort to result for billing + // Propagate ServiceTier and ReasoningEffort to result for billing. + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier,policy filter 删掉字段后不再 + // 按原请求 Fast 计费。 if handleErr == nil && result != nil { - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -475,6 +478,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) // cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4), diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index f3506d045a..923033c4d4 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -69,9 +69,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } clientStream := gjson.GetBytes(body, "stream").Bool() - // 1b. Extract service tier from the raw body before any transformation. - serviceTier := extractOpenAIServiceTierFromBody(body) - // 2. Resolve model mapping (same as ForwardAsChatCompletions) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) @@ -106,6 +103,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, policyErr } upstreamBody = updatedBody + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier; + // 最终值由 resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。 + serviceTier := extractOpenAIServiceTierFromBody(upstreamBody) if account.Platform == PlatformGrok { strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody) if stripErr != nil { @@ -390,7 +390,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -492,7 +492,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 0a4aa23416..c567a00be7 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1180,7 +1180,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index c9113050e1..75841f1061 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -492,9 +492,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if promptCacheKey != "" && anthropicDigestChain != "" { s.bindOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain, promptCacheKey, anthropicMatchedDigestChain) } - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier。 + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -572,6 +573,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index e2929be92e..31dccb969b 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -158,7 +158,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -204,7 +204,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( } } - scan := s.scanCCStream(resp, "openai messages chat fallback", requestID, startTime, emitChunk) + scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, emitChunk) usage := scan.Usage if scan.Err != nil { @@ -218,7 +218,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -253,7 +253,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index c8af9a70a1..a4375f681e 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -515,7 +515,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index cbf4e6e9c8..c9a61d7465 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1240,6 +1240,57 @@ func normalizeOpenAIServiceTier(raw string) *string { } } +// ErrInvalidOpenAIServiceTier indicates a request carried a service_tier value +// that is not a known OpenAI tier. HTTP handlers translate it into a 400 +// invalid_request_error so malformed values are rejected up front instead of +// being silently stripped (which would mask the user's intent to use fast +// mode). +type ErrInvalidOpenAIServiceTier struct { + Value string +} + +func (e *ErrInvalidOpenAIServiceTier) Error() string { + return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale", e.Value) +} + +const invalidOpenAIServiceTierValueMaxLen = 64 + +func boundInvalidOpenAIServiceTierValue(raw string) string { + if len(raw) <= invalidOpenAIServiceTierValueMaxLen { + return raw + } + return raw[:invalidOpenAIServiceTierValueMaxLen] + "..." +} + +// ValidateOpenAIServiceTierField validates the service_tier field of a raw +// OpenAI-compatible request body (/v1/responses and /v1/chat/completions). +// +// - absent / null → valid, returns "" (field omitted keeps current behavior) +// - "fast" → normalized to "priority" (the two are equivalent; the canonical +// value is what reaches the OpenAI upstream) +// - "priority" / "flex" / "auto" / "default" / "scale" → valid, returned as-is +// - an explicitly present non-string value, an empty string, or any other +// unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400), +// matching OpenAI's enum validation semantics +func ValidateOpenAIServiceTierField(body []byte) (string, error) { + tierResult := gjson.GetBytes(body, "service_tier") + if !tierResult.Exists() || tierResult.Type == gjson.Null { + return "", nil + } + if tierResult.Type != gjson.String { + return "", &ErrInvalidOpenAIServiceTier{Value: ""} + } + raw := strings.TrimSpace(tierResult.String()) + if raw == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: raw} + } + norm := normalizedOpenAIServiceTierValue(raw) + if norm == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: boundInvalidOpenAIServiceTierValue(raw)} + } + return norm, nil +} + // OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast // policy (action=block). Mirrors BetaBlockedError on the Claude side. type OpenAIFastBlockedError struct { diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 639d4a6bb3..1b4a00b09e 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -39,7 +39,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } clientStream := responsesReq.Stream - serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原 @@ -88,9 +87,10 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } - if serviceTier == nil { - serviceTier = extractOpenAIServiceTierFromBody(chatBody) - } + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier;最终值由 + // resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。filter 删掉字段后 + // 这里取到 nil,不再按原请求 Fast 计费。 + serviceTier := extractOpenAIServiceTierFromBody(chatBody) logger.L().Debug("openai responses: forwarding via raw chat completions", zap.Int64("account_id", account.ID), @@ -160,7 +160,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -216,7 +216,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c.Writer.Flush() } - scan := s.scanCCStream(resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { + scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state) s.cacheReasoningItemsFromEvents(events) writeEvents(events) @@ -230,7 +230,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -244,7 +244,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -274,7 +274,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 27d94ea54c..c4d085b024 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -253,10 +253,8 @@ type OpenAIForwardResult struct { // UpstreamEndpoint is the actual upstream API path used for this request. // It avoids guessing when one downstream protocol can use multiple upstream endpoints. UpstreamEndpoint string - // ServiceTier records the OpenAI Responses API service tier requested by the - // client, e.g. "priority" / "flex". Nil means the request did not specify a - // recognized tier. Usage recording lowers it to UpstreamResponseServiceTier - // when the upstream reports a cheaper tier (see ResolveBillingServiceTier). + // ServiceTier 优先取上游实际响应回显的 tier;缺失时回退到最终出站 body 的 + // tier。nil 表示两者都无识别 tier。 ServiceTier *string // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // Stored for usage records display; nil means not provided / not applicable. diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index dcd5989c99..06af82c7fb 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -776,7 +776,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), ImageCount: imageCounter.Count(), ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go new file mode 100644 index 0000000000..99bf2004a6 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// HTTP POST /v1/responses → forwardOpenAIWSV2 共用 stream/non-stream 的 +// OpenAIForwardResult:上游 response.completed.service_tier 必须覆盖请求 +// fast/priority,不能只读 reqBody。 +func TestForwardOpenAIWSV2_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + requestTier string + stream bool + }{ + {name: "priority_nonstream", requestTier: "priority", stream: false}, + {name: "fast_stream", requestTier: "fast", stream: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5882, + Name: "openai-ws-v2-tier", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + body := []byte(fmt.Sprintf( + `{"model":"gpt-5.5","stream":%t,"service_tier":%q,"input":[{"type":"input_text","text":"hi"}]}`, + tc.stream, tc.requestTier, + )) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.OpenAIWSMode, "must take HTTP POST → forwardOpenAIWSV2, not HTTP fallback") + require.Equal(t, tc.stream, result.Stream) + require.Equal(t, "resp_tier_v2", result.RequestID) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) + require.Equal(t, "priority", captureConn.lastWrite["service_tier"], + "outbound WS payload still carries the requested Fast tier") + }) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 962095b407..26a08c1bf1 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -514,7 +514,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), - ServiceTier: extractOpenAIServiceTierFromBody(body), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(body)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 7824591e07..884d7faec6 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -44,6 +44,45 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.Error(t, err) } +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + // proxyOpenAIWSHTTPBridgeTurn 是 client WS→HTTP bridge,本身不 canonicalize + // fast→priority;生产入口的归一化在 openai_ws_forwarder_ingress.go 的 fast + // policy。本测试只覆盖局部 observer:canonical 请求 priority 被上游 + // response.completed service_tier=default 覆盖。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5881, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"priority","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) +} + func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index eb52a76d1f..64cb048cc0 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -22,8 +22,10 @@ const ( // (see responseModelBillingDeclaration). // // The same observer also records the service tier the upstream reports having -// used (OpenAI service_tier, Anthropic usage.speed). Billing consumes it through -// ResolveBillingServiceTier, which only ever lowers the tier a request asked for. +// used (OpenAI service_tier, Anthropic usage.speed). The billable tier is +// resolved by resolvedOpenAIUpstreamServiceTierFromObserver (upstream echo +// first, outbound body tier as fallback); the upstream ResolveBillingServiceTier +// only-lowers path additionally audits downgrades at usage-record time. type upstreamResponseModelObserver struct { first string terminal string @@ -216,6 +218,33 @@ func observedUpstreamResponseServiceTier(c *gin.Context) string { return upstreamResponseModelObserverFromContext(c).ServiceTier() } +// resolvedOpenAIUpstreamServiceTierFromObserver 返回计费/用量日志实际使用的 +// service tier: +// +// 1. observer 记录到的上游真实回显优先——只有上游实际给了 priority/fast 才按 +// Fast 计费;上游回显 default/flex/auto 等则如实采用并据此计费; +// 2. 上游未回显时,回退到「最终出站 body」里的 tier(经过 fast policy +// filter/force 之后),保证 policy filter 删掉字段后不再按原请求 Fast 计费。 +// +// HTTP→WS 等使用局部 observer 的路径必须把该 observer 传进来,不能只读 +// Gin context——局部 observer 不会自动写入 context。 +func resolvedOpenAIUpstreamServiceTierFromObserver(observer *upstreamResponseModelObserver, outboundBodyTier *string) *string { + if observer != nil { + if tier := strings.TrimSpace(observer.ServiceTier()); tier != "" { + return normalizeOpenAIServiceTier(tier) + } + } + return outboundBodyTier +} + +// resolvedOpenAIUpstreamServiceTier 读取 Gin context 上的 observer 后委托 +// resolvedOpenAIUpstreamServiceTierFromObserver。标准 HTTP 转发路径通过 +// beginUpstreamResponseModelObservation 把 observer 挂到 context;局部 +// observer 路径应直接调用 FromObserver。 +func resolvedOpenAIUpstreamServiceTier(c *gin.Context, outboundBodyTier *string) *string { + return resolvedOpenAIUpstreamServiceTierFromObserver(upstreamResponseModelObserverFromContext(c), outboundBodyTier) +} + func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) { if observer == nil || strings.TrimSpace(body) == "" { return diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 7dc445e3fc..490e768f27 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -5162,12 +5162,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5177,7 +5177,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5210,12 +5210,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5225,7 +5225,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", From c0c3e1cb47318908462adb4695b02b7e12689c31 Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 24 Aug 2026 11:18:14 +0800 Subject: [PATCH 38/64] fix(openai): wire local observer service tier in WS ingress; bound handler tests - openai_ws_forwarder_ingress: resolve billing tier from the local upstreamResponseModelObserver (upstream echo first) instead of the raw request payload, matching the HTTP->WS bridge and WS v2 forwarder. - openai_ws_http_bridge_test: add fast-alias + upstream default case proving the local observer's echoed tier wins. - handler tests: keep only invalid service_tier -> 400 (short-circuits at validation); valid/omitted semantics covered by the pure service-level validation tests, avoiding real account selection in tests. --- .../openai_service_tier_validation_test.go | 45 +++---------------- .../service/openai_ws_forwarder_ingress.go | 2 +- .../service/openai_ws_http_bridge_test.go | 37 +++++++++++++++ 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go index b912f812f8..97da6f682c 100644 --- a/backend/internal/handler/openai_service_tier_validation_test.go +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -14,7 +14,12 @@ import ( ) // 非法 service_tier 必须在两个 OpenAI 端点(/v1/responses、/v1/chat/completions) -// 上以 OpenAI 兼容错误结构返回 HTTP 400;合法值(fast/priority)不被拒绝。 +// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier +// 校验处短路,不会进入账号选择/重试。 +// +// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由 +// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler +// 测试走入真实账号选择/重试路径。 func newServiceTierHandlerTest(t *testing.T) *OpenAIGatewayHandler { t.Helper() @@ -72,25 +77,6 @@ func TestOpenAIGatewayHandlerResponses_InvalidServiceTierRejected400(t *testing. } } -func TestOpenAIGatewayHandlerResponses_ValidServiceTierNotRejected(t *testing.T) { - for _, tier := range []string{"fast", "priority", "flex"} { - body := `{"model":"gpt-5.5","input":"hi","service_tier":"` + tier + `"}` - rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { - h.Responses(c) - }) - require.NotEqual(t, http.StatusBadRequest, rec.Code, "tier=%s must not be rejected as invalid", tier) - require.NotContains(t, rec.Body.String(), "invalid service_tier", "tier=%s", tier) - } -} - -func TestOpenAIGatewayHandlerResponses_ServiceTierOmittedKeepsCurrentBehavior(t *testing.T) { - body := `{"model":"gpt-5.5","input":"hi"}` - rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { - h.Responses(c) - }) - require.NotContains(t, rec.Body.String(), "invalid service_tier") -} - func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *testing.T) { for _, body := range []string{ `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"turbo"}`, @@ -106,22 +92,3 @@ func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *te require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) } } - -func TestOpenAIGatewayHandlerChatCompletions_ValidServiceTierNotRejected(t *testing.T) { - for _, tier := range []string{"fast", "priority", "auto", "default", "scale", "flex"} { - body := `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"` + tier + `"}` - rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { - h.ChatCompletions(c) - }) - require.NotEqual(t, http.StatusBadRequest, rec.Code, "tier=%q must not be rejected as invalid", tier) - require.NotContains(t, rec.Body.String(), "invalid service_tier", "tier=%q", tier) - } -} - -func TestOpenAIGatewayHandlerChatCompletions_ServiceTierOmittedKeepsCurrentBehavior(t *testing.T) { - body := `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}` - rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { - h.ChatCompletions(c) - }) - require.NotContains(t, rec.Body.String(), "invalid service_tier") -} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 124f8d76a7..0265ad6903 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -1164,7 +1164,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), - ServiceTier: extractOpenAIServiceTierFromBody(payload), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(payload)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 884d7faec6..75a93411fe 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -83,6 +83,43 @@ func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t require.Equal(t, "default", *result.ServiceTier) } +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultWinsOverFastAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 客户端别名 fast 同样被上游回显的 default 覆盖:局部 observer 的 + // ServiceTier() 是唯一计费依据,绝不回退到请求侧 fast。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier2","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5882, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"fast","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "local observer's upstream-echoed default must win over the fast alias") +} + func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { gin.SetMode(gin.TestMode) From e457f0fa22f2db3bea19b712ecc904a350141ebb Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 24 Aug 2026 11:43:44 +0800 Subject: [PATCH 39/64] fix(openai): adapt service tier observation to upstream constraints - cc_pipeline: observe Chat Completions chunks/bodies as untyped payloads (empty event type) so the upstream-echoed service_tier is trusted, matching the upstream constraint that only terminal events and untyped bodies report the actual processing tier. - tests: add model field to terminal SSE frames (observation only triggers on model-bearing frames) and assert response.created tier echo is ignored. --- .../service/openai_fast_service_tier_test.go | 21 ++++++++++++------- .../service/openai_gateway_cc_pipeline.go | 7 +++++-- .../service/openai_ws_forwarder_v2_test.go | 2 +- .../service/openai_ws_http_bridge_test.go | 4 ++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go index 31b500c8ba..57e1b59687 100644 --- a/backend/internal/service/openai_fast_service_tier_test.go +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -757,17 +757,24 @@ func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) { t.Parallel() observer := &upstreamResponseModelObserver{} + // 上游约束:非终止且有类型的事件(response.created)回显的是请求档位而非 + // 实际处理档位,忽略。 observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created") - require.Equal(t, "flex", observer.ServiceTier()) + require.Empty(t, observer.ServiceTier()) - // terminal 声明优先。 + // terminal 声明(带 model 帧)优先。 observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") require.Equal(t, "default", observer.ServiceTier()) - // Chat Completions 顶层 service_tier 同样可观察。 + // Chat Completions 顶层 service_tier 按 untyped payload 观察(无 type 字段)。 ccObserver := &upstreamResponseModelObserver{} - ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "chat.completion") + ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "") require.Equal(t, "priority", ccObserver.ServiceTier()) + + // 无 model 的帧不触发 tier 观察(上游约束:tier 声明必带 model)。 + modelFree := &upstreamResponseModelObserver{} + modelFree.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + require.Empty(t, modelFree.ServiceTier()) } func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { @@ -779,7 +786,7 @@ func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(nil) observer := beginUpstreamResponseModelObservation(c) - observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") got := resolvedOpenAIUpstreamServiceTier(c, priority) require.NotNil(t, got) @@ -800,7 +807,7 @@ func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(nil) observer := beginUpstreamResponseModelObservation(c) - observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"fast"}}`), "response.completed") + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"fast"}}`), "response.completed") got := resolvedOpenAIUpstreamServiceTier(c, nil) require.NotNil(t, got) @@ -819,7 +826,7 @@ func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { t.Run("local observer wins without gin context", func(t *testing.T) { observer := &upstreamResponseModelObserver{} - observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority) require.NotNil(t, got) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 8d45c4ff93..b0e98e5926 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -274,8 +274,10 @@ func (s *OpenAIGatewayService) scanCCStream( break } // 观察上游 CC chunk 回显的 model / service_tier(计费以回显为准)。 + // CC chunk 无 type 字段,按 untyped payload 观察(上游约束:只有终止 + // 事件与无类型 body 报告实际处理档位)。 if observer := upstreamResponseModelObserverFromContext(c); observer != nil { - observer.ObserveOpenAI([]byte(payload), "chat.completion.chunk") + observer.ObserveOpenAI([]byte(payload), "") } if u := extractCCStreamUsage(payload); u != nil { @@ -337,8 +339,9 @@ func (s *OpenAIGatewayService) readCCUpstreamJSONResponse( return nil, OpenAIUsage{}, fmt.Errorf("parse chat completions response: %w", err) } // 观察上游 CC JSON 回显的 model / service_tier(计费以回显为准)。 + // CC JSON 无 type 字段,按 untyped payload 观察(上游约束)。 if observer := upstreamResponseModelObserverFromContext(c); observer != nil { - observer.ObserveOpenAI(respBody, "chat.completion") + observer.ObserveOpenAI(respBody, "") } usage := OpenAIUsage{} diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go index 99bf2004a6..7cfd7f9928 100644 --- a/backend/internal/service/openai_ws_forwarder_v2_test.go +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -50,7 +50,7 @@ func TestForwardOpenAIWSV2_UpstreamDefaultServiceTierWinsOverRequest(t *testing. captureConn := &openAIWSCaptureConn{ events: [][]byte{ - []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), }, } captureDialer := &openAIWSCaptureDialer{conn: captureConn} diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 75a93411fe..59ee0a548e 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -52,7 +52,7 @@ func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t // policy。本测试只覆盖局部 observer:canonical 请求 priority 被上游 // response.completed service_tier=default 覆盖。 sse := strings.Join([]string{ - `data: {"type":"response.completed","response":{"id":"resp_tier","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + `data: {"type":"response.completed","response":{"id":"resp_tier","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, ``, }, "\n") upstream := &httpUpstreamRecorder{resp: &http.Response{ @@ -89,7 +89,7 @@ func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultWinsOverFastAlias(t *testing // 客户端别名 fast 同样被上游回显的 default 覆盖:局部 observer 的 // ServiceTier() 是唯一计费依据,绝不回退到请求侧 fast。 sse := strings.Join([]string{ - `data: {"type":"response.completed","response":{"id":"resp_tier2","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + `data: {"type":"response.completed","response":{"id":"resp_tier2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, ``, }, "\n") upstream := &httpUpstreamRecorder{resp: &http.Response{ From 1591477a3e401eb19281099dca9599db4a9cbc27 Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 24 Aug 2026 11:58:01 +0800 Subject: [PATCH 40/64] test(apicompat): adapt ChatCompletionsResponseToResponses call to upstream functionTools signature --- .../internal/pkg/apicompat/service_tier_passthrough_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/pkg/apicompat/service_tier_passthrough_test.go b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go index 58d3c83e07..6c8dd0205c 100644 --- a/backend/internal/pkg/apicompat/service_tier_passthrough_test.go +++ b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go @@ -112,7 +112,7 @@ func TestChatCompletionsResponseToResponses_PreservesServiceTier(t *testing.T) { }}, Usage: &ChatUsage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, } - resp := ChatCompletionsResponseToResponses(cc, "gpt-5.5", nil, false, nil) + resp := ChatCompletionsResponseToResponses(cc, "gpt-5.5", nil, nil, false, nil) require.Equal(t, "default", resp.ServiceTier) raw, err := json.Marshal(resp) @@ -121,7 +121,7 @@ func TestChatCompletionsResponseToResponses_PreservesServiceTier(t *testing.T) { } func TestChatCompletionsResponseToResponses_NilRespOmitsServiceTier(t *testing.T) { - resp := ChatCompletionsResponseToResponses(nil, "gpt-5.5", nil, false, nil) + resp := ChatCompletionsResponseToResponses(nil, "gpt-5.5", nil, nil, false, nil) require.Empty(t, resp.ServiceTier) } From cbe258fd1284f1286cdc1c3843d9958a6f49dad2 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sun, 23 Aug 2026 20:41:45 +0800 Subject: [PATCH 41/64] =?UTF-8?q?build:=20=E5=8D=87=E7=BA=A7=20Go=201.27.0?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=AD=A5=20CI/Dockerfile=20=E5=B9=B6?= =?UTF-8?q?=E9=80=82=E9=85=8D=20jsonv2=20=E4=B8=8E=20golangci-lint=20v2.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod 1.26.6 → 1.27.0;backend-ci/release/security-scan 的 go version 断言、 三个 Dockerfile 的 golang 镜像、README 徽章与 DEV_GUIDE 同步 - golangci-lint-action v2.9 → v2.13(v2.9 由 go1.26 构建,拒绝 go.mod 1.27 目标); 新规则按最小方式处理:排除 G703/G704 污点分析(网关按配置转发/写文件, 与既有 G304 排除策略一致)、reflect.Ptr → reflect.Pointer、 ResetQuota 恒返回错误的 SA4023 与 OIDC EC JWK 的 SA1019 加 nolint - ent 生成代码按 Go 1.27 默认 jsonv2 引擎重新生成:json.RawMessage 字段 生成为同类型别名 jsontext.Value(group.model_pricing / usage_cleanup_task.filters) - x/net v0.56 在 go1.27 下包装标准库 HTTP/2:ConfigureTransports 经 RegisterProtocol("http/2") 打开 Protocols.HTTP2 而不再写 TLSNextProto, ReadIdleTimeout/PingTimeout 建连时映射为 HTTP2Config.SendPingTimeout/PingTimeout; keepalive 测试改断言 Protocols.HTTP2(),并补真实 HTTP/2 协商用例 --- .github/workflows/backend-ci.yml | 6 +-- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- DEV_GUIDE.md | 4 +- Dockerfile | 2 +- README.md | 4 +- README_CN.md | 4 +- README_JA.md | 4 +- backend/.golangci.yml | 4 ++ backend/Dockerfile | 2 +- backend/ent/group.go | 3 +- backend/ent/group_create.go | 10 ++-- backend/ent/group_update.go | 10 ++-- backend/ent/mutation.go | 46 ++++++++--------- backend/ent/usagecleanuptask.go | 3 +- backend/ent/usagecleanuptask_create.go | 10 ++-- backend/ent/usagecleanuptask_update.go | 10 ++-- backend/go.mod | 2 +- .../handler/admin/grok_oauth_handler.go | 4 +- .../handler/admin/setting_handler_update.go | 2 +- backend/internal/handler/auth_oidc_oauth.go | 4 +- .../http_upstream_http2_keepalive_test.go | 49 +++++++++++++++++-- .../usage_log_repo_request_type_test.go | 2 +- deploy/Dockerfile | 2 +- 24 files changed, 120 insertions(+), 71 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index ece09c5dc1..2bac7259a9 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -33,7 +33,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: Unit tests working-directory: backend run: make test-unit @@ -73,10 +73,10 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.9 + version: v2.13 args: --timeout=30m working-directory: backend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ecc8a26ba..abdfc9c81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 0e68d950ac..97357efde2 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: Run govulncheck working-directory: backend run: | diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index bf5e8867d6..cb1ffabdb3 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -53,7 +53,7 @@ npm install -g pnpm ### CI 要求 -- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 +- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 - 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` ### 本地测试命令 @@ -203,7 +203,7 @@ go test -tags=integration ./... **解决**: ```bash cd backend -go generate ./ent # 重新生成 ent 代码 +go generate ./ent # 重新生成 ent 代码(json.RawMessage 字段会生成为同类型的 jsontext.Value,属预期) git add ent/ # 生成的文件也要提交 ``` diff --git a/Dockerfile b/Dockerfile index c7991c5a60..adfa4d4702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.6-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/README.md b/README.md index 50ffe7e430..e5d3221c1c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -194,7 +194,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.26.5, Gin, Ent | +| Backend | Go 1.27.0, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | diff --git a/README_CN.md b/README_CN.md index 034a3819f5..10329d4d8f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -196,7 +196,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.26.5, Gin, Ent | +| 后端 | Go 1.27.0, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/README_JA.md b/README_JA.md index 384f9daf67..8727367980 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -195,7 +195,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.26.5, Gin, Ent | +| バックエンド | Go 1.27.0, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 92ba391694..a4aed11470 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -57,6 +57,10 @@ linters: - G304 - G306 - G404 + # G703/G704 是 G304/G107 的污点分析版:网关本职就是按配置转发到上游 URL、按配置写本地文件, + # 污点来源即配置本身,规则不适用。 + - G703 + - G704 severity: high confidence: high errcheck: diff --git a/backend/Dockerfile b/backend/Dockerfile index 1efbd3859d..398d9bdfcf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.6-alpine +FROM golang:1.27.0-alpine WORKDIR /app diff --git a/backend/ent/group.go b/backend/ent/group.go index c9795fb58f..8f30c26470 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -100,7 +101,7 @@ type Group struct { // 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价 LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"` // 分组逐模型定价;优先级高于渠道和内置定价 - ModelPricing json.RawMessage `json:"model_pricing,omitempty"` + ModelPricing jsontext.Value `json:"model_pricing,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 158dca1e40..e5e92ccd3f 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -575,7 +575,7 @@ func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreat } // SetModelPricing sets the "model_pricing" field. -func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate { +func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate { _c.mutation.SetModelPricing(v) return _c } @@ -2445,7 +2445,7 @@ func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert { +func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert { u.Set(group.FieldModelPricing, v) return u } @@ -3615,7 +3615,7 @@ func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne { +func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) @@ -5005,7 +5005,7 @@ func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk { +func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index beca0c71f0..41e06b4471 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -803,13 +803,13 @@ func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdat } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.AppendModelPricing(v) return _u } @@ -2924,13 +2924,13 @@ func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUp } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.AppendModelPricing(v) return _u } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index ddf50a1c42..f777a0a5fb 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "sync" @@ -22142,8 +22142,8 @@ type GroupMutation struct { audio_stt_price_per_hour *float64 addaudio_stt_price_per_hour *float64 long_context_pricing_enabled *bool - model_pricing *json.RawMessage - appendmodel_pricing json.RawMessage + model_pricing *jsontext.Value + appendmodel_pricing jsontext.Value claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -24404,13 +24404,13 @@ func (m *GroupMutation) ResetLongContextPricingEnabled() { } // SetModelPricing sets the "model_pricing" field. -func (m *GroupMutation) SetModelPricing(jm json.RawMessage) { - m.model_pricing = &jm +func (m *GroupMutation) SetModelPricing(j jsontext.Value) { + m.model_pricing = &j m.appendmodel_pricing = nil } // ModelPricing returns the value of the "model_pricing" field in the mutation. -func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { +func (m *GroupMutation) ModelPricing() (r jsontext.Value, exists bool) { v := m.model_pricing if v == nil { return @@ -24421,7 +24421,7 @@ func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { // OldModelPricing returns the old "model_pricing" field's value of the Group entity. // If the Group object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) { +func (m *GroupMutation) OldModelPricing(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldModelPricing is only allowed on UpdateOne operations") } @@ -24435,13 +24435,13 @@ func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, return oldValue.ModelPricing, nil } -// AppendModelPricing adds jm to the "model_pricing" field. -func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) { - m.appendmodel_pricing = append(m.appendmodel_pricing, jm...) +// AppendModelPricing adds j to the "model_pricing" field. +func (m *GroupMutation) AppendModelPricing(j jsontext.Value) { + m.appendmodel_pricing = append(m.appendmodel_pricing, j...) } // AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation. -func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) { +func (m *GroupMutation) AppendedModelPricing() (jsontext.Value, bool) { if len(m.appendmodel_pricing) == 0 { return nil, false } @@ -26515,7 +26515,7 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { m.SetLongContextPricingEnabled(v) return nil case group.FieldModelPricing: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } @@ -43151,8 +43151,8 @@ type UsageCleanupTaskMutation struct { created_at *time.Time updated_at *time.Time status *string - filters *json.RawMessage - appendfilters json.RawMessage + filters *jsontext.Value + appendfilters jsontext.Value created_by *int64 addcreated_by *int64 deleted_rows *int64 @@ -43376,13 +43376,13 @@ func (m *UsageCleanupTaskMutation) ResetStatus() { } // SetFilters sets the "filters" field. -func (m *UsageCleanupTaskMutation) SetFilters(jm json.RawMessage) { - m.filters = &jm +func (m *UsageCleanupTaskMutation) SetFilters(j jsontext.Value) { + m.filters = &j m.appendfilters = nil } // Filters returns the value of the "filters" field in the mutation. -func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { +func (m *UsageCleanupTaskMutation) Filters() (r jsontext.Value, exists bool) { v := m.filters if v == nil { return @@ -43393,7 +43393,7 @@ func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { // OldFilters returns the old "filters" field's value of the UsageCleanupTask entity. // If the UsageCleanupTask object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMessage, err error) { +func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldFilters is only allowed on UpdateOne operations") } @@ -43407,13 +43407,13 @@ func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMe return oldValue.Filters, nil } -// AppendFilters adds jm to the "filters" field. -func (m *UsageCleanupTaskMutation) AppendFilters(jm json.RawMessage) { - m.appendfilters = append(m.appendfilters, jm...) +// AppendFilters adds j to the "filters" field. +func (m *UsageCleanupTaskMutation) AppendFilters(j jsontext.Value) { + m.appendfilters = append(m.appendfilters, j...) } // AppendedFilters returns the list of values that were appended to the "filters" field in this mutation. -func (m *UsageCleanupTaskMutation) AppendedFilters() (json.RawMessage, bool) { +func (m *UsageCleanupTaskMutation) AppendedFilters() (jsontext.Value, bool) { if len(m.appendfilters) == 0 { return nil, false } @@ -43964,7 +43964,7 @@ func (m *UsageCleanupTaskMutation) SetField(name string, value ent.Value) error m.SetStatus(v) return nil case usagecleanuptask.FieldFilters: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/backend/ent/usagecleanuptask.go b/backend/ent/usagecleanuptask.go index e3a17b5aed..b4dbf0fd34 100644 --- a/backend/ent/usagecleanuptask.go +++ b/backend/ent/usagecleanuptask.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -25,7 +26,7 @@ type UsageCleanupTask struct { // Status holds the value of the "status" field. Status string `json:"status,omitempty"` // Filters holds the value of the "filters" field. - Filters json.RawMessage `json:"filters,omitempty"` + Filters jsontext.Value `json:"filters,omitempty"` // CreatedBy holds the value of the "created_by" field. CreatedBy int64 `json:"created_by,omitempty"` // DeletedRows holds the value of the "deleted_rows" field. diff --git a/backend/ent/usagecleanuptask_create.go b/backend/ent/usagecleanuptask_create.go index 0b1dcff55e..b6dbcb3cc8 100644 --- a/backend/ent/usagecleanuptask_create.go +++ b/backend/ent/usagecleanuptask_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate { } // SetFilters sets the "filters" field. -func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate { +func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate { _c.mutation.SetFilters(v) return _c } @@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert { +func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert { u.Set(usagecleanuptask.FieldFilters, v) return u } @@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne { +func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) @@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk { +func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) diff --git a/backend/ent/usagecleanuptask_update.go b/backend/ent/usagecleanuptask_update.go index 604202c679..d233258bbe 100644 --- a/backend/ent/usagecleanuptask_update.go +++ b/backend/ent/usagecleanuptask_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.AppendFilters(v) return _u } @@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.AppendFilters(v) return _u } diff --git a/backend/go.mod b/backend/go.mod index 2f07042ce5..b86368117f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.6 +go 1.27.0 require ( entgo.io/ent v0.14.5 diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 696a24b419..fdab4473de 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) { response.BadRequest(c, "grok quota service is not enabled") return } + // ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。 + //nolint:staticcheck // SA4023 result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID) - if err != nil { + if err != nil { //nolint:staticcheck // SA4023 response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 3775c01e11..b2c001fe73 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -441,7 +441,7 @@ func buildSettingKeyByJSONName() map[string]string { out := make(map[string]string, t.NumField()) for i := 0; i < t.NumField(); i++ { field := t.Field(i) - if field.Type.Kind() == reflect.Ptr { + if field.Type.Kind() == reflect.Pointer { continue } name, _, _ := strings.Cut(field.Tag.Get("json"), ",") diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 0367e4cff0..13c26cf18c 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) { if err != nil { return nil, fmt.Errorf("decode ec y: %w", err) } - if !curve.IsOnCurve(x, y) { + if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移 return nil, errors.New("ec point is not on curve") } - return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上 default: return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty) } diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index ead61da3b2..66a30dab0e 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -1,7 +1,10 @@ package repository import ( + "context" + "crypto/x509" "net/http" + "net/http/httptest" "net/url" "testing" "time" @@ -19,6 +22,16 @@ func http2KeepAliveTestPoolSettings() poolSettings { } } +// requireHTTP2Configured 断言 http2 已显式挂到 http.Transport 上。 +// x/net/http2 在 go1.27 && !http2legacy 下是标准库 HTTP/2 的包装:ConfigureTransports 通过 +// Transport.RegisterProtocol("http/2") 注册配置并打开 Protocols.HTTP2(TLSNextProto 不承载 h2 入口), +// ReadIdleTimeout/PingTimeout 在建连时映射为 http.HTTP2Config.SendPingTimeout/PingTimeout。 +func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { + t.Helper() + require.NotNil(t, tr.Protocols, msg) + require.True(t, tr.Protocols.HTTP2(), msg) +} + // Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: // 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的 // http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。 @@ -34,7 +47,7 @@ func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") - require.NotNil(t, tr.TLSNextProto["h2"], "http2 必须已挂到底层 http.Transport 上") + requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") } // openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 @@ -42,17 +55,45 @@ func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2") - require.NotNil(t, tr.TLSNextProto["h2"], "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") + requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } // 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, -// 构建时 TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 +// 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) require.NoError(t, err) + require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") } +// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: +// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。 +func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) + require.NoError(t, err) + defer tr.CloseIdleConnections() + require.NotNil(t, tr.TLSClientConfig) + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + tr.TLSClientConfig.RootCAs = roots + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := tr.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2") +} + // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { @@ -62,6 +103,6 @@ func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testi tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2) - require.NotNil(t, tr.TLSNextProto["h2"], "经代理的 openai_h2 也必须启用 http2 keepalive") + requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive") require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index c69f374d41..a67c93c345 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -780,7 +780,7 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } for i := range dest { dv := reflect.ValueOf(dest[i]) - if dv.Kind() != reflect.Ptr { + if dv.Kind() != reflect.Pointer { return fmt.Errorf("dest[%d] is not pointer", i) } dv.Elem().Set(reflect.ValueOf(s.values[i])) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 30c7a1a240..ab312c4852 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.6-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn From 3b8177642905323201ddac5ac4e27b1f310ab9b9 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sun, 23 Aug 2026 20:41:45 +0800 Subject: [PATCH 42/64] =?UTF-8?q?fix(test):=20grok=20QueryQuota=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8E=92=E9=99=A4=E5=90=8E=E5=8F=B0=20/v1/mo?= =?UTF-8?q?dels=20=E5=90=8C=E6=AD=A5=E8=AF=B7=E6=B1=82=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E8=AF=B7=E6=B1=82=E8=AE=A1=E6=95=B0=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryQuota 返回前经 scheduleGrokObservedModelsSync 异步拉取 GET /v1/models, 该请求是否早于 upstream.snapshot() 落到 mock 取决于调度时序,三个精确断言 请求数的用例约 0.5% 偶发多出一条。新增 quotaSnapshot 只返回配额探测链路的 请求,三处断言改用它。 --- .../service/grok_quota_service_test.go | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 70ee2c8d72..9d94d1b989 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -301,6 +301,22 @@ func (u *grokHybridUpstream) snapshot() ([]*http.Request, [][]byte) { return requests, bodies } +// quotaSnapshot 返回配额探测链路的请求及请求体,不含 scheduleGrokObservedModelsSync +// 在 QueryQuota 返回后异步发出的 GET /v1/models:该请求是否已落到上游取决于调度时序。 +func (u *grokHybridUpstream) quotaSnapshot() ([]*http.Request, [][]byte) { + requests, bodies := u.snapshot() + quotaRequests := make([]*http.Request, 0, len(requests)) + quotaBodies := make([][]byte, 0, len(bodies)) + for i, req := range requests { + if req.URL.Path == "/v1/models" { + continue + } + quotaRequests = append(quotaRequests, req) + quotaBodies = append(quotaBodies, bodies[i]) + } + return quotaRequests, quotaBodies +} + func (r *grokQuotaProxyRepo) GetByID(_ context.Context, id int64) (*Proxy, error) { r.calls++ return r.proxies[id], nil @@ -741,7 +757,7 @@ func TestGrokQuotaServiceQueryQuotaFreeFallsBackToGrok45(t *testing.T) { require.EqualValues(t, 2_000_000, *result.Snapshot.Tokens.Limit) require.True(t, result.HeadersObserved) - requests, bodies := upstream.snapshot() + requests, bodies := upstream.quotaSnapshot() require.Len(t, requests, 3) responseCalls := 0 for i, req := range requests { @@ -781,7 +797,7 @@ func TestGrokQuotaServiceQueryQuotaPaidBillingSkipsActiveProbe(t *testing.T) { require.Empty(t, result.Model) require.Nil(t, result.LocalUsage24h) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) @@ -806,7 +822,7 @@ func TestGrokQuotaServiceQueryQuotaCustomPaidMonthlyLimitSkipsActiveProbe(t *tes require.InDelta(t, monthlyLimit, *result.Billing.MonthlyLimitCents, 1e-9) require.Nil(t, result.Snapshot) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) From 73aabc861cacbfc9240bb306ef0aa04511d0164f Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 24 Aug 2026 12:19:50 +0800 Subject: [PATCH 43/64] =?UTF-8?q?build:=20=E5=8F=96=E6=B6=88=20gosec=20G70?= =?UTF-8?q?3/G704=20=E5=85=A8=E5=B1=80=E6=8E=92=E9=99=A4=EF=BC=8C=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E4=BB=A3=E7=A0=81=E9=80=90=E7=82=B9=20nolint=E3=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E6=8C=89=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=B1=81=E5=85=8D=EF=BC=9BDEV=5FGUIDE=20=E5=90=8C=E6=AD=A5=20g?= =?UTF-8?q?olangci-lint=20v2.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEV_GUIDE.md | 6 +++--- backend/.golangci.yml | 14 ++++++++++---- backend/internal/pkg/servertiming/http.go | 4 ++-- backend/internal/service/gateway_service.go | 6 +++--- backend/internal/service/pricing_service.go | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index cb1ffabdb3..2f440d0f62 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -34,8 +34,8 @@ ### 开发工具 ```bash -# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音) -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9 +# golangci-lint(CI 用 v2.13,本地建议装同一版以免版本差异带来的噪音) +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13 # pnpm (前端包管理) npm install -g pnpm @@ -47,7 +47,7 @@ npm install -g pnpm | Workflow | 触发条件 | 检查内容 | |----------|----------|----------| -| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 | +| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.13 | | **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit | | **release.yml** | tag `v*` | 构建发布(PR 不触发) | diff --git a/backend/.golangci.yml b/backend/.golangci.yml index a4aed11470..08d1b61f45 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -57,10 +57,6 @@ linters: - G304 - G306 - G404 - # G703/G704 是 G304/G107 的污点分析版:网关本职就是按配置转发到上游 URL、按配置写本地文件, - # 污点来源即配置本身,规则不适用。 - - G703 - - G704 severity: high confidence: high errcheck: @@ -125,6 +121,16 @@ linters: # Default: true — must be true, ent generates 130K+ lines of code generated-is-used: true + exclusions: + rules: + # G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的 + # 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的 + # 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免, + # 必须逐点 //nolint:gosec 并写明信任边界。 + - path: '_test\.go$' + linters: [ gosec ] + text: 'G70[34]' + formatters: enable: - gofmt diff --git a/backend/internal/pkg/servertiming/http.go b/backend/internal/pkg/servertiming/http.go index e326e24302..2ad8a04ae1 100644 --- a/backend/internal/pkg/servertiming/http.go +++ b/backend/internal/pkg/servertiming/http.go @@ -53,10 +53,10 @@ func Do(client *http.Client, req *http.Request) (*http.Response, error) { client = http.DefaultClient } if req == nil || !Active(req.Context()) { - return client.Do(req) + return client.Do(req) //nolint:gosec // G704: 通用埋点包装器,不构造 URL;请求由调用方构造,SSRF 信任边界在调用方 } startedAt := time.Now() - response, err := client.Do(req) + response, err := client.Do(req) //nolint:gosec // G704: 同上 RecordDependency(req.Context(), dependencyModule(req), startedAt, time.Now()) return response, err } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 0c0140b338..37bab2f97d 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1520,19 +1520,19 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) { } // 如果 path 指向一个已存在的目录,自动追加默认文件名 - if info, err := os.Stat(path); err == nil && info.IsDir() { + if info, err := os.Stat(path); err == nil && info.IsDir() { //nolint:gosec // G703: path 仅来自启动环境变量 SUB2API_DEBUG_GATEWAY_BODY(运维配置),非请求输入 path = filepath.Join(path, debugGatewayBodyDefaultFilename) } // 确保父目录存在 if dir := filepath.Dir(path); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0755); err != nil { //nolint:gosec // G703: 同上 slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) return } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) //nolint:gosec // G703: 同上 if err != nil { slog.Error("failed to open gateway debug log file", "path", path, "error", err) return diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f7733ae001..6ae24dd39e 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -596,7 +596,7 @@ func (s *PricingService) useFallbackPricing() error { } pricingFile := s.getPricingFilePath() - if err := os.WriteFile(pricingFile, data, 0644); err != nil { + if err := os.WriteFile(pricingFile, data, 0644); err != nil { //nolint:gosec // G703: 路径为配置的数据目录 + 硬编码文件名,非请求输入 logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } From 269a4092418b57b36312991fdc35afdc918761eb Mon Sep 17 00:00:00 2001 From: alfadb Date: Mon, 24 Aug 2026 12:37:53 +0800 Subject: [PATCH 44/64] fix(openai): harden flaky alloc guard in tool schema sanitize test --- backend/internal/service/openai_responses_tool_schema_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index cf676645e1..faaf9062de 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -587,7 +587,9 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits }) // 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。 - require.Less(t, largeAllocs, smallAllocs+40, + // 干净环境实测 large 约 17 allocs,200 是 10 倍余量,同时容忍 CI 慢 pod 上 + // 包内后台 goroutine(日志/ticker)对进程级 Mallocs 的噪声污染。 + require.Less(t, largeAllocs, 200.0, "分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs) // 同时确认大 body 的结果确实全部修好了。 From 695ebede70e0bed4c8fd4c87b5a426448a08ea4c Mon Sep 17 00:00:00 2001 From: yan9651688 <174289968+yan9651688@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:05:12 +0800 Subject: [PATCH 45/64] fix(billing): normalize CN Anthropic usage tokens --- backend/internal/pkg/apicompat/types.go | 14 + .../service/gateway_anthropic_passthrough.go | 67 +++++ .../service/gateway_forward_as_responses.go | 44 +++- .../service/kimi_anthropic_usage_test.go | 248 ++++++++++++++++++ ...penai_gateway_messages_anthropic_native.go | 6 +- 5 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 backend/internal/service/kimi_anthropic_usage_test.go diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b96d16b911..ab6df351ef 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -155,12 +155,26 @@ func AnthropicStopReasonString(p *string) string { return *p } +// AnthropicPromptTokensDetails holds OpenAI-compatible prompt token details +// occasionally included by Anthropic-compatible providers. +type AnthropicPromptTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` +} + // AnthropicUsage holds token counts in Anthropic format. type AnthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` + // Anthropic-compatible providers can also expose their native OpenAI-style + // total/cache fields. Preserve them so callers can normalize provider totals + // into Anthropic's mutually-exclusive billing buckets. + PromptTokens int `json:"prompt_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + PromptTokensDetails *AnthropicPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"` } // --------------------------------------------------------------------------- diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 320d800cec..ededd5b830 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -698,6 +698,72 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { usage.CacheCreationInputTokens = int(total) } } + + // Kimi's Anthropic-compatible stream uses input_tokens with two meanings: + // message_start reports total prompt input, while message_delta reports only + // uncached input. prompt_tokens remains the total in both events. Normalize + // to ClaudeUsage's mutually-exclusive buckets so downstream billing does not + // subtract cache tokens from an already-uncached value. + usageNode := parsed.Get("usage") + if parsed.Get("type").String() == "message_start" { + usageNode = parsed.Get("message.usage") + } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) +} + +// normalizeAnthropicCompatiblePromptUsage converts provider-native OpenAI-style +// prompt/cache fields into Claude's mutually-exclusive usage buckets. Native +// Anthropic responses do not expose these aliases and are left alone. +func normalizeAnthropicCompatiblePromptUsage(usageNode gjson.Result, usage *ClaudeUsage) bool { + if usage == nil || !usageNode.Exists() { + return false + } + promptTokens := usageNode.Get("prompt_tokens") + promptCacheHitTokens := usageNode.Get("prompt_cache_hit_tokens") + promptCacheMissTokens := usageNode.Get("prompt_cache_miss_tokens") + if (!promptTokens.Exists() || promptTokens.Int() <= 0) && + !promptCacheHitTokens.Exists() && !promptCacheMissTokens.Exists() { + return false + } + + cacheReadTokens := usage.CacheReadInputTokens + if v := usageNode.Get("cache_read_input_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + if cacheReadTokens == 0 { + if v := usageNode.Get("cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 { + if v := usageNode.Get("prompt_tokens_details.cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 && promptCacheHitTokens.Exists() { + cacheReadTokens = max(int(promptCacheHitTokens.Int()), 0) + } + + cacheCreationTokens := usage.CacheCreationInputTokens + if v := usageNode.Get("cache_creation_input_tokens"); v.Exists() { + cacheCreationTokens = int(v.Int()) + } + if cacheCreationTokens == 0 { + cc5m := usageNode.Get("cache_creation.ephemeral_5m_input_tokens").Int() + cc1h := usageNode.Get("cache_creation.ephemeral_1h_input_tokens").Int() + if cc5m > 0 || cc1h > 0 { + cacheCreationTokens = int(cc5m + cc1h) + } + } + + if promptCacheMissTokens.Exists() { + usage.InputTokens = max(int(promptCacheMissTokens.Int()), 0) + } else { + usage.InputTokens = max(int(promptTokens.Int())-cacheReadTokens-cacheCreationTokens, 0) + } + usage.CacheReadInputTokens = cacheReadTokens + usage.CacheCreationInputTokens = cacheCreationTokens + return true } func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { @@ -731,6 +797,7 @@ func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { usage.CacheReadInputTokens = int(cached) } } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) return usage } diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index cdd0197eab..6fcb1b2ed0 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -285,18 +285,46 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { if dst == nil { return } - if src.InputTokens > 0 { - dst.InputTokens = src.InputTokens + + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache + // fields. Prefer those authoritative totals or hit/miss buckets over the + // overloaded input_tokens field. This covers Kimi's changing stream + // semantics as well as GLM/DeepSeek cache aliases. + if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + + if src.PromptCacheMissTokens != nil { + dst.InputTokens = max(*src.PromptCacheMissTokens, 0) + } else { + dst.InputTokens = max(src.PromptTokens-cacheReadTokens-src.CacheCreationInputTokens, 0) + } + dst.CacheReadInputTokens = cacheReadTokens + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } else { + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } else if src.CachedTokens > 0 { + dst.CacheReadInputTokens = src.CachedTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } } if src.OutputTokens > 0 { dst.OutputTokens = src.OutputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } - if src.CacheCreationInputTokens > 0 { - dst.CacheCreationInputTokens = src.CacheCreationInputTokens - } } // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". diff --git a/backend/internal/service/kimi_anthropic_usage_test.go b/backend/internal/service/kimi_anthropic_usage_test.go new file mode 100644 index 0000000000..458ba9fb39 --- /dev/null +++ b/backend/internal/service/kimi_anthropic_usage_test.go @@ -0,0 +1,248 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +func TestParseSSEUsagePassthroughNormalizesKimiPromptUsage(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"prompt_tokens":173306,"cached_tokens":0}}}`, usage) + require.Equal(t, 173306, usage.InputTokens) + require.Zero(t, usage.CacheReadInputTokens) + + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":250,"cache_creation_input_tokens":0,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, usage) + require.Equal(t, 250, usage.InputTokens, "Kimi message_delta input_tokens is already the uncached bucket") + require.Equal(t, 173056, usage.CacheReadInputTokens) + require.Equal(t, 166, usage.OutputTokens) +} + +func TestParseSSEUsagePassthroughKimiFullyCachedInputReplacesStartTotal(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306}}}`, usage) + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":0,"cache_read_input_tokens":173306,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306}}`, usage) + + require.Zero(t, usage.InputTokens, "an explicit zero uncached bucket must not retain message_start's total") + require.Equal(t, 173306, usage.CacheReadInputTokens) +} + +func TestParseClaudeUsageFromResponseBodyNormalizesCNProviderAliases(t *testing.T) { + tests := []struct { + name string + body string + wantInput int + wantCacheRead int + wantOutput int + }{ + { + name: "Kimi top-level cached_tokens", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"cache_read_input_tokens":173056,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + wantCacheRead: 173056, + wantOutput: 166, + }, + { + name: "GLM nested prompt cache details", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + { + name: "DeepSeek prompt cache hit and miss buckets", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, tt.wantOutput, usage.OutputTokens) + }) + } +} + +func TestParseSSEUsagePassthroughNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + data string + wantInput int + wantCacheRead int + }{ + { + name: "GLM", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + }, + { + name: "DeepSeek", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := &ClaudeUsage{} + parseSSEUsagePassthrough(tt.data, usage) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, 30, usage.OutputTokens) + }) + } +} + +func TestMergeAnthropicUsageNormalizesKimiStreamForOpenAIBilling(t *testing.T) { + var start apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306,"cached_tokens":0}}}`), &start)) + var delta apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_delta","usage":{"input_tokens":250,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`), &delta)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, start.Message.Usage) + mergeAnthropicUsage(usage, *delta.Usage) + require.Equal(t, 250, usage.InputTokens) + require.Equal(t, 173056, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 173306, openAIUsage.InputTokens, "OpenAI gateway expects an inclusive input total") + require.Equal(t, 250, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, 166, openAIUsage.OutputTokens) +} + +func TestMergeAnthropicUsageNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "GLM", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`, + }, + { + name: "DeepSeek", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var src apicompat.AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(tt.raw), &src)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, src) + require.Equal(t, 400, usage.InputTokens) + require.Equal(t, 800, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 1200, openAIUsage.InputTokens) + require.Equal(t, 400, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestClaudeUsageToOpenAIUsagePreservesCNProviderNativeAnthropicBuckets(t *testing.T) { + tests := []struct { + name string + usage ClaudeUsage + wantTotal int + wantUncached int + }{ + { + name: "GLM", + usage: ClaudeUsage{ + InputTokens: 2, + OutputTokens: 302, + CacheCreationInputTokens: 733, + CacheReadInputTokens: 376156, + }, + wantTotal: 376891, + wantUncached: 2, + }, + { + name: "DeepSeek", + usage: ClaudeUsage{ + InputTokens: 400, + OutputTokens: 30, + CacheReadInputTokens: 800, + }, + wantTotal: 1200, + wantUncached: 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + openAIUsage := claudeUsageToOpenAIUsage(&tt.usage) + require.Equal(t, tt.wantTotal, openAIUsage.InputTokens) + require.Equal(t, tt.wantUncached, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, tt.usage.CacheReadInputTokens, openAIUsage.CacheReadInputTokens) + require.Equal(t, tt.usage.CacheCreationInputTokens, openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestCNProviderAnthropicUsageBillsUncachedInput(t *testing.T) { + tests := []struct { + name string + model string + body string + wantInput int + }{ + { + name: "Kimi", + model: "k3", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + }, + { + name: "GLM", + model: "glm-5.2", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + }, + { + name: "DeepSeek", + model: "deepseek-v4-flash", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + }, + } + + billing := NewBillingService(&config.Config{}, nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeUsage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + openAIUsage := claudeUsageToOpenAIUsage(claudeUsage) + uncachedInput := max(openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens, 0) + require.Equal(t, tt.wantInput, uncachedInput) + + cost, err := billing.CalculateCost(tt.model, UsageTokens{ + InputTokens: uncachedInput, + OutputTokens: openAIUsage.OutputTokens, + CacheCreationTokens: openAIUsage.CacheCreationInputTokens, + CacheReadTokens: openAIUsage.CacheReadInputTokens, + }, 1) + require.NoError(t, err) + require.Positive(t, cost.InputCost, "uncached input must contribute to the final charge") + + pricing, err := billing.GetModelPricing(tt.model) + require.NoError(t, err) + require.InDelta(t, float64(tt.wantInput)*pricing.InputPricePerToken, cost.InputCost, 1e-12) + }) + } +} diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 8f107300b4..a4bc7b9f3a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -530,13 +530,15 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult( } // claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的 -// 用量结构(字段一一对应)。 +// 用量结构。Anthropic 的 input_tokens 不含缓存读写,而 OpenAI 网关内部 +// 约定 InputTokens 是包含缓存明细的总输入;这里必须先合并,RecordUsage +// 才能准确拆回互斥的计费桶。 func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage { if u == nil { return OpenAIUsage{} } return OpenAIUsage{ - InputTokens: u.InputTokens, + InputTokens: u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens, OutputTokens: u.OutputTokens, CacheCreationInputTokens: u.CacheCreationInputTokens, CacheReadInputTokens: u.CacheReadInputTokens, From 6f972145b72f0631c0ff77aa534df9585dd724e1 Mon Sep 17 00:00:00 2001 From: NellPoi Date: Sun, 23 Aug 2026 23:40:46 +0800 Subject: [PATCH 46/64] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20OpenAI=20?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=8D=A1=E6=8C=89=E7=94=A8=E9=87=8F=E9=98=88?= =?UTF-8?q?=E5=80=BC=E8=87=AA=E5=8A=A8=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire.go | 7 + backend/cmd/server/wire_gen.go | 12 +- backend/cmd/server/wire_gen_test.go | 1 + .../handler/admin/openai_oauth_handler.go | 77 +- backend/internal/handler/wire.go | 1 + .../internal/service/account_usage_service.go | 7 +- backend/internal/service/admin_account.go | 15 + .../service/openai_account_scheduler.go | 3 + .../openai_gateway_chat_completions.go | 2 + .../service/openai_gateway_forward.go | 2 + .../service/openai_gateway_messages.go | 2 + .../service/openai_gateway_passthrough.go | 2 + .../service/openai_gateway_scheduling.go | 33 + .../internal/service/openai_gateway_usage.go | 4 +- .../service/openai_quota_auto_reset.go | 809 ++++++++++++++++++ .../service/openai_quota_auto_reset_config.go | 148 ++++ .../service/openai_quota_auto_reset_test.go | 332 +++++++ .../service/openai_quota_reset_credits.go | 24 + .../openai_quota_reset_credits_test.go | 8 +- .../service/openai_quota_reset_workflow.go | 86 ++ .../internal/service/openai_quota_service.go | 40 +- .../service/openai_quota_spark_window_test.go | 26 + backend/internal/service/ratelimit_service.go | 6 + backend/internal/service/wire.go | 24 + .../components/account/EditAccountModal.vue | 82 ++ .../account/OpenAIQuotaResetCell.vue | 62 ++ .../__tests__/EditAccountModal.spec.ts | 72 ++ .../OpenAIQuotaResetCell.spark_shadow.spec.ts | 42 + .../src/i18n/locales/en/admin/accounts.ts | 16 + .../src/i18n/locales/zh/admin/accounts.ts | 16 + frontend/src/types/index.ts | 11 + 31 files changed, 1906 insertions(+), 66 deletions(-) create mode 100644 backend/internal/service/openai_quota_auto_reset.go create mode 100644 backend/internal/service/openai_quota_auto_reset_config.go create mode 100644 backend/internal/service/openai_quota_auto_reset_test.go create mode 100644 backend/internal/service/openai_quota_reset_workflow.go diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index a38962ffdb..06720610ba 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -125,6 +125,7 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, pluginManager *service.PluginManager, ) func() { @@ -145,6 +146,12 @@ func provideCleanup( } return nil }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1300989dc6..9a40b19afc 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -318,7 +318,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -345,7 +346,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService, pluginManager) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -426,6 +427,7 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, pluginManager *service.PluginManager, ) func() { @@ -445,6 +447,12 @@ func provideCleanup( } return nil }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index a4858cc363..b3581641ea 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -94,6 +94,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // upstreamBillingProbe nil, // ollamaCloudUsage nil, // auditLog + nil, // openAIAutoReset nil, // promptAudit nil, // pluginManager ) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index d7cfc8d2a6..ce83d4bfe8 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -34,12 +34,6 @@ type openAIAccountStateRecoverer interface { RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) } -const ( - openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" - openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" - openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" -) - // openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the // (non-refundable) reset credit has already been consumed upstream. The whole // request must stay comfortably inside the panel HTTP client timeout, otherwise @@ -47,6 +41,14 @@ const ( // it — spending a second credit. const openAIQuotaResetPostProcessTimeout = 8 * time.Second +// 保留 handler 包内的告警码名称,避免共享工作流下沉到 service 后破坏既有测试和包内调用。 +// 实际值统一引用 service 常量,防止手动重置与自动重置的响应码发生漂移。 +const ( + openAIQuotaResetWarningCacheRefreshFailed = service.OpenAIQuotaResetWarningCacheRefreshFailed + openAIQuotaResetWarningAccountRecoveryFailed = service.OpenAIQuotaResetWarningAccountRecoveryFailed + openAIQuotaResetWarningAccountRefreshFailed = service.OpenAIQuotaResetWarningAccountRefreshFailed +) + type openAIQuotaResetResponse struct { service.OpenAIQuotaResetResult Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"` @@ -493,6 +495,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.ErrorFrom(c, err) return } + service.NotifyOpenAIAutoResetCredit(accountID) response.Success(c, usage) } @@ -523,6 +526,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result") return } + service.NotifyOpenAIAutoResetCredit(accountID) refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} // A failed snapshot write leaves the previous cache intact — report it as a @@ -600,54 +604,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context()) defer cancelPost() - // Step 1 — unblocking the account is the whole point of consuming a credit - // (#3672 / #3740), so it runs FIRST and is never gated on the display cache. - // Recovery is DB-only and leaves the manual `schedulable` switch untouched. - if h.rateLimitService == nil { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return + postResult := service.RunOpenAIQuotaResetPostProcess( + postCtx, + accountID, + h.quotaService, + h.rateLimitService, + h.adminService.GetAccount, + ) + resetResponse.Quota = postResult.Quota + resetResponse.CacheRefreshed = postResult.CacheRefreshed + resetResponse.AccountStateRecovered = postResult.AccountStateRecovered + resetResponse.WarningCode = postResult.WarningCode + if postResult.Account != nil { + resetResponse.Account = dto.AccountFromService(postResult.Account) } - if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{ - InvalidateToken: true, - }); err != nil { - // Recovery failures are almost always storage-level; the remaining steps - // share that dependency, so stop here instead of compounding the failure. - slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return - } - resetResponse.AccountStateRecovered = true - - // Step 2 — refresh the reset-credit display cache. A failure here is reported - // but must not hide the recovered account row produced by step 3. - usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID) - switch { - case usageErr != nil || usage == nil: - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - default: - if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil { - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - } else { - resetResponse.Quota = usage - resetResponse.CacheRefreshed = true - } - } - - // Step 3 — hand back the post-recovery account row so the list drops the - // stale rate-limit badge without waiting for the next poll. - account, err := h.adminService.GetAccount(postCtx, accountID) - if err != nil { - slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err) - if resetResponse.WarningCode == "" { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed - } - response.Success(c, resetResponse) - return - } - resetResponse.Account = dto.AccountFromService(account) response.Success(c, resetResponse) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index bd11f72f9a..0e75dc9258 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -196,6 +196,7 @@ func ProvideHandlers( batchImageHandler *BatchImageHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, + _ *service.OpenAIQuotaAutoResetService, ) *Handlers { return &Handlers{ Auth: authHandler, diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 1661eca7c6..f49d3aaa71 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -729,6 +729,9 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou if updates := buildCodexSparkWindowExtraUpdates(quotaUsage, now); len(updates) > 0 { mergeAccountExtra(account, updates) s.persistOpenAICodexProbeSnapshot(account.ID, updates) + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } if usage.UpdatedAt == nil { usage.UpdatedAt = &now } @@ -921,7 +924,9 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u go func() { updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) defer updateCancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index cc09fb808f..ae194f61e1 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -470,6 +470,10 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + accountExtra, err = normalizeOpenAIAutoResetCreditExtra(input.Platform, input.Type, false, accountExtra) + if err != nil { + return nil, err + } // 绑定分组 groupIDs := input.GroupIDs @@ -559,6 +563,14 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + effectiveType := account.Type + if input.Type != "" { + effectiveType = input.Type + } + normalizedExtra, err = normalizeOpenAIAutoResetCreditExtra(account.Platform, effectiveType, account.IsShadow(), normalizedExtra) + if err != nil { + return nil, err + } } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) @@ -649,6 +661,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey, + OpenAIAutoResetCreditStateExtraKey, } { if v, ok := account.Extra[key]; ok { normalizedExtra[key] = v @@ -860,6 +873,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U // (如 model_rate_limits / passive_usage_* 等)。 func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { updates = sanitizedCodexFingerprintExtraUpdates(updates) + updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) delete(updates, UpstreamBillingRateSyncEnabledExtraKey) delete(updates, UpstreamBillingProbeExtraKey) @@ -886,6 +900,7 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { // Managed probe/session state may only enter through dedicated typed endpoints. input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra) + input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true) delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey) delete(input.Extra, UpstreamBillingProbeExtraKey) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 2f2fef563b..2986f6abeb 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1780,6 +1780,9 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con // rechecks won't reach healthy accounts that fell outside TopK — manifesting as // "no available accounts" even though healthy ones exist. if paused, decision := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { + if decision.reason != "" { + return false, decision.reason + } reason := "quota_auto_pause" if decision.window != "" { reason += "_" + decision.window diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index d5639fbe31..e2b947c18b 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -389,6 +389,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 0a4aa23416..fda1b2ce21 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1164,6 +1164,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index c9113050e1..71295c9511 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -508,6 +508,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index c8af9a70a1..8e8039b5b6 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -500,6 +500,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 5a0de5cd65..2a4a4f3d09 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -450,6 +450,7 @@ type openAIQuotaAutoPauseDecision struct { window string threshold float64 utilization float64 + reason string } func shouldAutoPauseGrokAccountByQuota(account *Account) (bool, openAIQuotaAutoPauseDecision) { @@ -520,6 +521,38 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) if account == nil || !account.IsOpenAI() { return false, openAIQuotaAutoPauseDecision{} } + // 自动用卡有独立阈值:达到消费阈值时必须先退出调度;仅达到普通暂停阈值时, + // 只有新鲜状态明确存在可用卡才继续放行到消费阈值。 + if config := ResolveOpenAIAutoResetCreditConfig(account); config.Enabled { + now := time.Now() + utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + if has5h && utilization5h >= config.Threshold5h { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"} + } + if has7d && utilization7d >= config.Threshold7d { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"} + } + + disabled5h := resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") + disabled7d := resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account) + pauseReached5h := !disabled5h && pause5h > 0 && has5h && utilization5h >= pause5h + pauseReached7d := !disabled7d && pause7d > 0 && has7d && utilization7d >= pause7d + if pauseReached5h || pauseReached7d { + state := openAIAutoResetStateFromExtra(account.Extra) + if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) { + return false, openAIQuotaAutoPauseDecision{} + } + notifyOpenAIAutoReset(account.ID) + if pauseReached5h { + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"} + } + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: pause7d, utilization: utilization7d, reason: "quota_auto_reset_credit_check_7d"} + } + } // Per-account explicit-disable flags must take precedence over the global default. // Without these, leaving the account threshold blank means "use global default", // so an admin has no way to exempt a single account from auto-pause once a global diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 25af28973a..7489456bb8 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -1075,7 +1075,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc go func() { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go new file mode 100644 index 0000000000..aafc5991d7 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset.go @@ -0,0 +1,809 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sort" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/google/uuid" +) + +const ( + openAIAutoResetScanInterval = time.Minute + openAIAutoResetSnapshotTTL = openAIProbeCacheTTL + openAIAutoResetBatchSize = 100 + openAIAutoResetWorkerCount = 4 + openAIAutoResetQueueCapacity = 1024 + openAIAutoResetAttemptTTL = 8 * 24 * time.Hour + openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit" +) + +const ( + OpenAIAutoResetStatusChecking = "checking" + OpenAIAutoResetStatusAvailable = "available" + OpenAIAutoResetStatusResetting = "resetting" + OpenAIAutoResetStatusSuccess = "success" + OpenAIAutoResetStatusNoCredit = "no_credit" + OpenAIAutoResetStatusFailed = "failed" +) + +// OpenAIAutoResetCreditState 是可返回管理端的脱敏运行态。Attempt* 仅保存不可逆 +// 指纹,用于重启后拒绝切换到另一张卡;不会保存卡 ID 或兑换 ID。 +type OpenAIAutoResetCreditState struct { + Status string `json:"status"` + TriggerWindow string `json:"trigger_window,omitempty"` + AvailableCount int `json:"available_count"` + CheckedAt string `json:"checked_at,omitempty"` + LastResultAt string `json:"last_result_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + AttemptCycleHash string `json:"attempt_cycle_hash,omitempty"` + AttemptCreditHash string `json:"attempt_credit_hash,omitempty"` +} + +type openAIAutoResetQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error + ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) +} + +type openAIAutoResetContextKey struct{} + +func withOpenAIAutoResetContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, openAIAutoResetContextKey{}, true) +} + +func isOpenAIAutoResetContext(ctx context.Context) bool { + if ctx == nil { + return false + } + value, _ := ctx.Value(openAIAutoResetContextKey{}).(bool) + return value +} + +type openAIAutoResetRecovery interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaAutoResetService 通过小型去重队列承接实时信号,并用分钟扫描补偿 +// 重启、漏事件和多实例读取;真正消费仍由 PostgreSQL 幂等记录串行化。 +type OpenAIQuotaAutoResetService struct { + accountRepo AccountRepository + quota openAIAutoResetQuota + recoverer openAIAutoResetRecovery + idempotency *IdempotencyCoordinator + audit *AuditLogService + settings *SettingService + leaderLock LeaderLockCache + + ctx context.Context + cancel context.CancelFunc + queue chan int64 + pending sync.Map + owner string + start sync.Once + stop sync.Once + wg sync.WaitGroup +} + +func NewOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quota openAIAutoResetQuota, + recoverer openAIAutoResetRecovery, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settings *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenAIQuotaAutoResetService{ + accountRepo: accountRepo, + quota: quota, + recoverer: recoverer, + idempotency: idempotency, + audit: audit, + settings: settings, + leaderLock: leaderLock, + ctx: ctx, + cancel: cancel, + queue: make(chan int64, openAIAutoResetQueueCapacity), + owner: uuid.NewString(), + } +} + +func (s *OpenAIQuotaAutoResetService) Start() { + if s == nil || s.accountRepo == nil || s.quota == nil || s.idempotency == nil { + return + } + s.start.Do(func() { + setOpenAIAutoResetNotifier(s) + for range openAIAutoResetWorkerCount { + s.wg.Add(1) + go s.runWorker() + } + s.wg.Add(1) + go s.runScanner() + }) +} + +func (s *OpenAIQuotaAutoResetService) Stop() { + if s == nil { + return + } + s.stop.Do(func() { + clearOpenAIAutoResetNotifier(s) + s.cancel() + s.wg.Wait() + }) +} + +// Notify 是请求热路径的非阻塞入口。同一账号尚在队列时只保留一个任务;队列 +// 满时丢弃本次信号,分钟扫描仍会补偿,因此不会反向拖慢网关请求。 +func (s *OpenAIQuotaAutoResetService) Notify(accountID int64) { + if s == nil || accountID <= 0 { + return + } + if _, loaded := s.pending.LoadOrStore(accountID, struct{}{}); loaded { + return + } + select { + case <-s.ctx.Done(): + s.pending.Delete(accountID) + case s.queue <- accountID: + default: + s.pending.Delete(accountID) + slog.Warn("openai_auto_reset_queue_full", "account_id", accountID) + } +} + +func (s *OpenAIQuotaAutoResetService) runWorker() { + defer s.wg.Done() + for { + select { + case <-s.ctx.Done(): + return + case accountID := <-s.queue: + ctx, cancel := context.WithTimeout(s.ctx, 50*time.Second) + if err := s.evaluateAccount(ctx, accountID); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("openai_auto_reset_evaluate_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + } + cancel() + s.pending.Delete(accountID) + } + } +} + +func (s *OpenAIQuotaAutoResetService) runScanner() { + defer s.wg.Done() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case <-s.ctx.Done(): + return + case <-timer.C: + s.scanEnabledAccounts(s.ctx) + } + ticker := time.NewTicker(openAIAutoResetScanInterval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.scanEnabledAccounts(s.ctx) + } + } +} + +func (s *OpenAIQuotaAutoResetService) scanEnabledAccounts(ctx context.Context) { + release, scan := s.tryAcquireScanLock(ctx) + if !scan { + return + } + if release != nil { + defer release() + } + for page := 1; ; page++ { + accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{ + Page: page, PageSize: openAIAutoResetBatchSize, + }, PlatformOpenAI, AccountTypeOAuth, StatusActive, "", 0, "") + if err != nil { + slog.Warn("openai_auto_reset_scan_failed", "page", page, "error", err) + return + } + for i := range accounts { + account := &accounts[i] + if account.Schedulable && ResolveOpenAIAutoResetCreditConfig(account).Enabled { + s.Notify(account.ID) + } + } + if len(accounts) < openAIAutoResetBatchSize || pageInfo == nil || page >= pageInfo.Pages { + return + } + } +} + +// Redis 锁异常时允许重复扫描,避免协调设施故障导致所有实例同时停止补偿; +// 消费唯一性由数据库幂等记录负责,扫描锁只用于削减重复查询。 +func (s *OpenAIQuotaAutoResetService) tryAcquireScanLock(ctx context.Context) (func(), bool) { + if s.leaderLock == nil { + return func() {}, true + } + ok, err := s.leaderLock.TryAcquireLeaderLock(ctx, openAIAutoResetLeaderLockKey, s.owner, 55*time.Second) + if err != nil { + slog.Warn("openai_auto_reset_leader_lock_unavailable", "error", err) + return func() {}, true + } + if !ok { + return nil, false + } + return func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.leaderLock.ReleaseLeaderLock(releaseCtx, openAIAutoResetLeaderLockKey, s.owner) + }, true +} + +type openAIAutoResetAssessment struct { + triggerWindow string + resetReached bool + pauseReached bool + utilization5h float64 + utilization7d float64 + threshold5h float64 + threshold7d float64 +} + +func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accountID int64) error { + ctx = withOpenAIAutoResetContext(ctx) + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + if account.IsShadow() { + if account.ParentAccountID != nil { + s.Notify(*account.ParentAccountID) + } + return nil + } + config := ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled || !account.IsActive() || !account.Schedulable { + return nil + } + + now := time.Now() + assessment := s.assessExtra(account, config, now) + state := openAIAutoResetStateFromExtra(account.Extra) + needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached + if assessment.pauseReached && !assessment.resetReached { + needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now) + } + if !needsQuery { + if !assessment.pauseReached && state != nil && state.TriggerWindow != "" { + state.TriggerWindow = "" + state.ErrorCode = "" + state.CheckedAt = now.UTC().Format(time.RFC3339) + if state.AvailableCount > 0 { + state.Status = OpenAIAutoResetStatusAvailable + } else { + state.Status = OpenAIAutoResetStatusNoCredit + } + return s.persistState(ctx, accountID, state) + } + return nil + } + + checking := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusChecking, + TriggerWindow: assessment.triggerWindow, + AvailableCount: stateAvailableCount(state), + CheckedAt: now.UTC().Format(time.RFC3339), + } + copyOpenAIAutoResetAttempt(checking, state) + if err := s.persistState(ctx, accountID, checking); err != nil { + return err + } + + usage, err := s.quota.QueryUsage(ctx, accountID) + if err != nil || usage == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_QUERY_FAILED", err) + } + if err := s.persistFreshUsage(ctx, accountID, usage, now); err != nil { + return s.failState(ctx, accountID, checking, "USAGE_SNAPSHOT_WRITE_FAILED", err) + } + if usage.RateLimitResetCredits == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_DETAILS_UNAVAILABLE", nil) + } + + // 查询期间管理员可能关闭开关;消费前重新读取账号,确保尚未发出的任务可取消。 + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + config = ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled { + return nil + } + assessment = s.assessUsage(usage, account, config, now) + available := usage.RateLimitResetCredits.AvailableCount + if !assessment.resetReached { + status := OpenAIAutoResetStatusNoCredit + if available > 0 { + status = OpenAIAutoResetStatusAvailable + } + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: status, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + }) + } + if available <= 0 { + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: now.UTC().Format(time.RFC3339), + LastResultAt: now.UTC().Format(time.RFC3339), + ErrorCode: "NO_RESET_CREDIT", + }) + } + + cycleSeed := openAIAutoResetCycleSeed(usage) + cycleHash := shortOpenAIAutoResetHash(cycleSeed) + candidate, selectErr := selectOpenAIAutoResetCandidate(usage.autoResetCandidates, available, state, cycleHash) + if selectErr != nil { + failed := checking + failed.AvailableCount = available + failed.TriggerWindow = assessment.triggerWindow + failed.AttemptCycleHash = cycleHash + return s.failState(ctx, accountID, failed, infraerrors.Reason(selectErr), selectErr) + } + creditHash := shortOpenAIAutoResetHash(candidate.ID) + stableKey := fmt.Sprintf("oarc:%d:%s:%s", accountID, creditHash, cycleHash) + redeemRequestID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(stableKey)).String() + resetting := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusResetting, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if err := s.persistState(ctx, accountID, resetting); err != nil { + return err + } + + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil || !ResolveOpenAIAutoResetCreditConfig(account).Enabled { + return err + } + result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{ + Scope: "openai_auto_reset_credit", + ActorScope: fmt.Sprintf("account:%d", accountID), + Method: http.MethodPost, + Route: "/system/openai/reset-credit/auto", + IdempotencyKey: stableKey, + Payload: map[string]any{ + "account_id": accountID, + "credit_hash": creditHash, + "cycle_hash": cycleHash, + }, + TTL: openAIAutoResetAttemptTTL, + RequireKey: true, + }, func(execCtx context.Context) (any, error) { + resetResult, resetErr := s.quota.ResetCreditTargeted(execCtx, accountID, candidate.ID, redeemRequestID) + if resetErr != nil { + return nil, resetErr + } + if resetResult == nil { + return nil, infraerrors.InternalServer("OPENAI_AUTO_RESET_EMPTY_RESULT", "automatic reset returned an empty result") + } + // 幂等表只保存脱敏结果,避免上游返回的卡 ID 被持久化到响应体列。 + return openAIAutoResetConsumeResult{Code: resetResult.Code, WindowsReset: resetResult.WindowsReset}, nil + }) + if err != nil { + // 另一个实例已持有同一周期的兑换时保持 resetting,等待下一轮读取同一 + // 幂等结果;不能把并发冲突误报成上游消费失败,更不能改选下一张卡。 + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(ErrIdempotencyInProgress) || reason == infraerrors.Reason(ErrIdempotencyRetryBackoff) { + return nil + } + s.recordAudit(accountID, assessment, available, "failed", 0, infraerrors.Reason(err)) + return s.failState(ctx, accountID, resetting, infraerrors.Reason(err), err) + } + + consumeResult := decodeOpenAIAutoResetConsumeResult(result.Data) + if strings.EqualFold(strings.TrimSpace(consumeResult.Code), "no_credit") { + noCreditAt := time.Now().UTC().Format(time.RFC3339) + noCredit := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: noCreditAt, + LastResultAt: noCreditAt, + ErrorCode: "NO_RESET_CREDIT", + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + s.recordAudit(accountID, assessment, available, "no_credit", 0, noCredit.ErrorCode) + return s.persistState(ctx, accountID, noCredit) + } + postCtx, cancelPost := context.WithTimeout(context.WithoutCancel(ctx), 8*time.Second) + post := RunOpenAIQuotaResetPostProcess(postCtx, accountID, s.quota, s.recoverer, s.accountRepo.GetByID) + cancelPost() + if !post.AccountStateRecovered || post.WarningCode != "" { + code := post.WarningCode + if code == "" { + code = OpenAIQuotaResetWarningAccountRecoveryFailed + } + s.recordAudit(accountID, assessment, available, "recovery_failed", consumeResult.WindowsReset, code) + return s.failState(ctx, accountID, resetting, code, nil) + } + + successAt := time.Now().UTC().Format(time.RFC3339) + success := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusSuccess, + TriggerWindow: assessment.triggerWindow, + AvailableCount: max(0, available-1), + CheckedAt: successAt, + LastResultAt: successAt, + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if post.Quota != nil && post.Quota.RateLimitResetCredits != nil { + success.AvailableCount = post.Quota.RateLimitResetCredits.AvailableCount + } + if err := s.persistState(ctx, accountID, success); err != nil { + return err + } + s.recordAudit(accountID, assessment, available, "success", consumeResult.WindowsReset, "") + slog.Info("openai_auto_reset_credit_success", + "account_id", accountID, + "trigger_window", assessment.triggerWindow, + "threshold_5h", assessment.threshold5h, + "threshold_7d", assessment.threshold7d, + "utilization_5h", assessment.utilization5h, + "utilization_7d", assessment.utilization7d, + "windows_reset", consumeResult.WindowsReset, + ) + return nil +} + +type openAIAutoResetConsumeResult struct { + Code string `json:"code"` + WindowsReset int `json:"windows_reset"` +} + +func decodeOpenAIAutoResetConsumeResult(value any) openAIAutoResetConsumeResult { + if typed, ok := value.(openAIAutoResetConsumeResult); ok { + return typed + } + raw, _ := json.Marshal(value) + var decoded openAIAutoResetConsumeResult + _ = json.Unmarshal(raw, &decoded) + return decoded +} + +func (s *OpenAIQuotaAutoResetService) assessExtra(account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + utilization5h, _ := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, _ := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) assessUsage(usage *OpenAIQuotaUsage, account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + utilization5h := readOpenAIQuotaUsedPercent(updates, "5h") / 100 + utilization7d := readOpenAIQuotaUsedPercent(updates, "7d") / 100 + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) buildAssessment(account *Account, config OpenAIAutoResetCreditConfig, utilization5h, utilization7d float64) openAIAutoResetAssessment { + assessment := openAIAutoResetAssessment{ + utilization5h: utilization5h, + utilization7d: utilization7d, + threshold5h: config.Threshold5h, + threshold7d: config.Threshold7d, + } + reset5h := utilization5h >= config.Threshold5h + reset7d := utilization7d >= config.Threshold7d + assessment.resetReached = reset5h || reset7d + assessment.triggerWindow = joinOpenAIAutoResetWindows(reset5h, reset7d) + + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(context.Background(), account) + if s.settings != nil { + pause5h, pause7d = resolveOpenAIQuotaAutoPauseThresholds( + withOpenAIQuotaAutoPauseSettings(context.Background(), s.settings.GetOpenAIQuotaAutoPauseSettings(context.Background())), + account, + ) + } + pauseReached5h := !resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") && pause5h > 0 && utilization5h >= pause5h + pauseReached7d := !resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") && pause7d > 0 && utilization7d >= pause7d + assessment.pauseReached = pauseReached5h || pauseReached7d || assessment.resetReached + if assessment.triggerWindow == "" { + assessment.triggerWindow = joinOpenAIAutoResetWindows(pauseReached5h, pauseReached7d) + } + return assessment +} + +func joinOpenAIAutoResetWindows(fiveHour, sevenDay bool) string { + switch { + case fiveHour && sevenDay: + return "5h+7d" + case fiveHour: + return "5h" + case sevenDay: + return "7d" + default: + return "" + } +} + +func buildOpenAIAutoResetUsageUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any { + if usage == nil || usage.RateLimit == nil { + return nil + } + rateLimit := usage.RateLimit + snapshot := &OpenAICodexUsageSnapshot{UpdatedAt: now.UTC().Format(time.RFC3339)} + applyWindow := func(window *OpenAIRateLimitWindow, primary bool) { + if window == nil { + return + } + used := window.UsedPercent + resetAfter := int(window.ResetAfterSeconds) + windowMinutes := int(window.LimitWindowSeconds / 60) + if primary { + snapshot.PrimaryUsedPercent = &used + snapshot.PrimaryResetAfterSeconds = &resetAfter + snapshot.PrimaryWindowMinutes = &windowMinutes + } else { + snapshot.SecondaryUsedPercent = &used + snapshot.SecondaryResetAfterSeconds = &resetAfter + snapshot.SecondaryWindowMinutes = &windowMinutes + } + } + applyWindow(rateLimit.PrimaryWindow, true) + applyWindow(rateLimit.SecondaryWindow, false) + return buildCodexUsageExtraUpdates(snapshot, now) +} + +func (s *OpenAIQuotaAutoResetService) persistFreshUsage(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage, now time.Time) error { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + if len(updates) > 0 { + if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil { + return err + } + } + return s.quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits) +} + +func selectOpenAIAutoResetCandidate(candidates []openAIAutoResetCreditCandidate, available int, previous *OpenAIAutoResetCreditState, cycleHash string) (openAIAutoResetCreditCandidate, error) { + if available <= 0 { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_NO_CREDIT", "no reset credit is available") + } + if len(candidates) < available { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") + } + for _, candidate := range candidates { + if _, err := time.Parse(time.RFC3339, candidate.ExpiresAt); err != nil { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_EXPIRY_INVALID", "reset credit expiration is invalid") + } + } + sorted := append([]openAIAutoResetCreditCandidate(nil), candidates...) + sort.SliceStable(sorted, func(i, j int) bool { + left, leftErr := time.Parse(time.RFC3339, sorted[i].ExpiresAt) + right, rightErr := time.Parse(time.RFC3339, sorted[j].ExpiresAt) + if leftErr != nil { + return false + } + if rightErr != nil { + return true + } + return left.Before(right) + }) + if previous != nil && previous.AttemptCycleHash == cycleHash && previous.AttemptCreditHash != "" { + for _, candidate := range sorted { + if shortOpenAIAutoResetHash(candidate.ID) == previous.AttemptCreditHash { + if strings.TrimSpace(candidate.ID) == "" { + break + } + return candidate, nil + } + } + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_ORIGINAL_CREDIT_UNAVAILABLE", "the original reset credit cannot be confirmed; refusing to switch credits") + } + if len(sorted) == 0 || strings.TrimSpace(sorted[0].ID) == "" { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_ID_MISSING", "the earliest reset credit has no official id") + } + return sorted[0], nil +} + +func openAIAutoResetCycleSeed(usage *OpenAIQuotaUsage) string { + if usage == nil || usage.RateLimit == nil { + return "5h:0|7d:0" + } + var fiveHour, sevenDay int64 + for _, window := range []*OpenAIRateLimitWindow{usage.RateLimit.PrimaryWindow, usage.RateLimit.SecondaryWindow} { + if window == nil { + continue + } + resetAt := window.ResetAt + if resetAt <= 0 { + resetAt = usage.FetchedAt + window.ResetAfterSeconds + } + if window.LimitWindowSeconds <= 6*60*60 { + fiveHour = resetAt + } else { + sevenDay = resetAt + } + } + return fmt.Sprintf("5h:%d|7d:%d", fiveHour, sevenDay) +} + +func shortOpenAIAutoResetHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:12]) +} + +func openAIAutoResetSnapshotStale(extra map[string]any, now time.Time) bool { + if len(extra) == 0 { + return true + } + raw, ok := extra["codex_usage_updated_at"] + if !ok { + return true + } + updatedAt, err := parseTime(fmt.Sprint(raw)) + return err != nil || now.Sub(updatedAt) >= openAIAutoResetSnapshotTTL +} + +func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditState { + if len(extra) == 0 { + return nil + } + raw, ok := extra[OpenAIAutoResetCreditStateExtraKey] + if !ok || raw == nil { + return nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil + } + var state OpenAIAutoResetCreditState + if err := json.Unmarshal(encoded, &state); err != nil || state.Status == "" { + return nil + } + return &state +} + +func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool { + if state == nil || state.CheckedAt == "" { + return true + } + checkedAt, err := time.Parse(time.RFC3339, state.CheckedAt) + return err != nil || now.Sub(checkedAt) >= openAIAutoResetSnapshotTTL +} + +func stateAvailableCount(state *OpenAIAutoResetCreditState) int { + if state == nil { + return 0 + } + return state.AvailableCount +} + +func copyOpenAIAutoResetAttempt(target, source *OpenAIAutoResetCreditState) { + if target == nil || source == nil { + return + } + target.AttemptCycleHash = source.AttemptCycleHash + target.AttemptCreditHash = source.AttemptCreditHash +} + +func (s *OpenAIQuotaAutoResetService) persistState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState) error { + if state == nil { + return nil + } + return s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{OpenAIAutoResetCreditStateExtraKey: state}) +} + +func (s *OpenAIQuotaAutoResetService) failState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState, code string, cause error) error { + if state == nil { + state = &OpenAIAutoResetCreditState{} + } + if strings.TrimSpace(code) == "" { + code = "OPENAI_AUTO_RESET_FAILED" + } + state.Status = OpenAIAutoResetStatusFailed + state.ErrorCode = code + state.LastResultAt = time.Now().UTC().Format(time.RFC3339) + if err := s.persistState(ctx, accountID, state); err != nil { + return err + } + slog.Warn("openai_auto_reset_credit_failed", + "account_id", accountID, + "trigger_window", state.TriggerWindow, + "available_count", state.AvailableCount, + "error_code", code, + ) + if cause != nil { + return cause + } + return infraerrors.Conflict(code, "automatic reset credit operation failed") +} + +func (s *OpenAIQuotaAutoResetService) recordAudit(accountID int64, assessment openAIAutoResetAssessment, available int, resultCode string, windowsReset int, errorCode string) { + if s.audit == nil { + return + } + statusCode := http.StatusOK + if resultCode != "success" { + statusCode = http.StatusConflict + } + s.audit.Record(&AuditLog{ + ActorEmail: "system", + ActorRole: "system", + AuthMethod: "system", + Action: "system.openai.reset_credit.auto", + Method: "SYSTEM", + Path: fmt.Sprintf("/system/openai/accounts/%d/auto-reset-credit", accountID), + StatusCode: statusCode, + Extra: map[string]any{ + "account_id": accountID, + "trigger_window": assessment.triggerWindow, + "threshold_5h": assessment.threshold5h, + "threshold_7d": assessment.threshold7d, + "utilization_5h": assessment.utilization5h, + "utilization_7d": assessment.utilization7d, + "available_count": available, + "result_code": resultCode, + "windows_reset": windowsReset, + "error_code": errorCode, + }, + }) +} + +var openAIAutoResetNotifierRegistry struct { + sync.RWMutex + service *OpenAIQuotaAutoResetService +} + +func setOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + openAIAutoResetNotifierRegistry.service = service + openAIAutoResetNotifierRegistry.Unlock() +} + +func clearOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + if openAIAutoResetNotifierRegistry.service == service { + openAIAutoResetNotifierRegistry.service = nil + } + openAIAutoResetNotifierRegistry.Unlock() +} + +func notifyOpenAIAutoReset(accountID int64) { + openAIAutoResetNotifierRegistry.RLock() + service := openAIAutoResetNotifierRegistry.service + openAIAutoResetNotifierRegistry.RUnlock() + if service != nil { + service.Notify(accountID) + } +} + +// NotifyOpenAIAutoResetCredit 供额度查询入口发送轻量信号;不执行同步上游请求。 +func NotifyOpenAIAutoResetCredit(accountID int64) { + notifyOpenAIAutoReset(accountID) +} diff --git a/backend/internal/service/openai_quota_auto_reset_config.go b/backend/internal/service/openai_quota_auto_reset_config.go new file mode 100644 index 0000000000..e891049795 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_config.go @@ -0,0 +1,148 @@ +package service + +import ( + "encoding/json" + "math" + "net/http" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIAutoResetCreditEnabledExtraKey = "auto_reset_credit_enabled" + OpenAIAutoResetCredit5hThresholdExtraKey = "auto_reset_credit_5h_threshold" + OpenAIAutoResetCredit7dThresholdExtraKey = "auto_reset_credit_7d_threshold" + OpenAIAutoResetCreditStateExtraKey = "codex_auto_reset_credit_state" + + openAIAutoResetCreditDefaultThreshold = 1.0 + openAIAutoResetCreditMinimumThreshold = 0.001 +) + +// OpenAIAutoResetCreditConfig 是账号级自动用卡配置。阈值采用 0-1 比例, +// 避免后端调度与前端百分比展示混用同一数值语义。 +type OpenAIAutoResetCreditConfig struct { + Enabled bool + Threshold5h float64 + Threshold7d float64 +} + +// ResolveOpenAIAutoResetCreditConfig 只接受 OpenAI OAuth 母账号;历史账号未配置时 +// 始终保持关闭,防止升级后产生意外消费。 +func ResolveOpenAIAutoResetCreditConfig(account *Account) OpenAIAutoResetCreditConfig { + config := OpenAIAutoResetCreditConfig{ + Threshold5h: openAIAutoResetCreditDefaultThreshold, + Threshold7d: openAIAutoResetCreditDefaultThreshold, + } + if !isOpenAIAutoResetCreditAccount(account) || account.Extra == nil { + return config + } + config.Enabled = resolveAccountExtraBool(account.Extra, OpenAIAutoResetCreditEnabledExtraKey) + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit5hThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold5h = value + } + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit7dThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold7d = value + } + return config +} + +func isOpenAIAutoResetCreditAccount(account *Account) bool { + return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth && !account.IsShadow() +} + +// normalizeOpenAIAutoResetCreditExtra 校验管理请求中的配置并剥离服务运行态。 +// enabled=true 时补齐两个 100% 默认阈值;关闭时保留已设置阈值,方便再次开启。 +func normalizeOpenAIAutoResetCreditExtra(platform, accountType string, isShadow bool, extra map[string]any) (map[string]any, error) { + if extra == nil { + return nil, nil + } + normalized := cloneOpenAIAutoResetExtra(extra) + delete(normalized, OpenAIAutoResetCreditStateExtraKey) + + _, hasEnabled := normalized[OpenAIAutoResetCreditEnabledExtraKey] + _, has5h := normalized[OpenAIAutoResetCredit5hThresholdExtraKey] + _, has7d := normalized[OpenAIAutoResetCredit7dThresholdExtraKey] + if !hasEnabled && !has5h && !has7d { + return normalized, nil + } + if platform != PlatformOpenAI || accountType != AccountTypeOAuth || isShadow { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ACCOUNT_INVALID", "automatic reset credits are only supported for OpenAI OAuth parent accounts") + } + + enabled := false + if hasEnabled { + value, ok := normalized[OpenAIAutoResetCreditEnabledExtraKey].(bool) + if !ok { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ENABLED_INVALID", "auto_reset_credit_enabled must be a boolean") + } + enabled = value + } + for key, present := range map[string]bool{ + OpenAIAutoResetCredit5hThresholdExtraKey: has5h, + OpenAIAutoResetCredit7dThresholdExtraKey: has7d, + } { + if !present { + if enabled { + normalized[key] = openAIAutoResetCreditDefaultThreshold + } + continue + } + value, ok := parseOpenAIAutoResetThreshold(normalized[key]) + if !ok || !isValidOpenAIAutoResetThreshold(value) { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_THRESHOLD_INVALID", "%s must be between 0.001 and 1.0", key) + } + normalized[key] = value + } + return normalized, nil +} + +func stripOpenAIAutoResetCreditManagedExtra(extra map[string]any, stripConfig bool) map[string]any { + if extra == nil { + return nil + } + delete(extra, OpenAIAutoResetCreditStateExtraKey) + if stripConfig { + delete(extra, OpenAIAutoResetCreditEnabledExtraKey) + delete(extra, OpenAIAutoResetCredit5hThresholdExtraKey) + delete(extra, OpenAIAutoResetCredit7dThresholdExtraKey) + } + return extra +} + +func parseOpenAIAutoResetThreshold(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func isValidOpenAIAutoResetThreshold(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= openAIAutoResetCreditMinimumThreshold && value <= 1 +} + +func cloneOpenAIAutoResetExtra(source map[string]any) map[string]any { + if source == nil { + return nil + } + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} diff --git a/backend/internal/service/openai_quota_auto_reset_test.go b/backend/internal/service/openai_quota_auto_reset_test.go new file mode 100644 index 0000000000..ad2dc965aa --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_test.go @@ -0,0 +1,332 @@ +package service + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeOpenAIAutoResetCreditExtra(t *testing.T) { + t.Run("历史账号默认关闭", func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + config := ResolveOpenAIAutoResetCreditConfig(account) + require.False(t, config.Enabled) + require.Equal(t, 1.0, config.Threshold5h) + require.Equal(t, 1.0, config.Threshold7d) + }) + + t.Run("开启时补齐两个百分百阈值并剥离运行态", func(t *testing.T) { + extra, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCreditStateExtraKey: map[string]any{"status": "success"}, + }) + require.NoError(t, err) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit5hThresholdExtraKey]) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit7dThresholdExtraKey]) + require.NotContains(t, extra, OpenAIAutoResetCreditStateExtraKey) + }) + + t.Run("阈值和账号类型严格校验", func(t *testing.T) { + _, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.0009, + }) + require.Error(t, err) + + _, err = normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, true, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + }) + require.Error(t, err) + }) +} + +func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T) { + now := time.Now().UTC() + baseExtra := map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "auto_pause_5h_threshold": 0.8, + "auto_pause_7d_disabled": true, + "codex_5h_used_percent": 90.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + } + + t.Run("卡状态未知时暂停并触发异步查询", func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: cloneOpenAIAutoResetExtra(baseExtra)} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_credit_check_5h", decision.reason) + }) + + t.Run("明确有卡时允许继续到用卡阈值", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) + + t.Run("达到用卡阈值后即使有卡也退出调度", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_pending_5h", decision.reason) + }) + + t.Run("自然窗口重置后清除动态阻塞", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra["codex_5h_reset_at"] = now.Add(-time.Second).Format(time.RFC3339) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusFailed, TriggerWindow: "5h", ErrorCode: "RESET_FAILED", + } + account := &Account{ID: 4, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) +} + +func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { + candidates := []openAIAutoResetCreditCandidate{ + {ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"}, + {ID: "earlier", ExpiresAt: "2026-09-01T00:00:00Z"}, + } + selected, err := selectOpenAIAutoResetCandidate(candidates, 2, nil, "cycle-a") + require.NoError(t, err) + require.Equal(t, "earlier", selected.ID) + + _, err = selectOpenAIAutoResetCandidate([]openAIAutoResetCreditCandidate{ + {ExpiresAt: "2026-09-01T00:00:00Z"}, + }, 1, nil, "cycle-a") + require.Error(t, err) + + _, err = selectOpenAIAutoResetCandidate(candidates, 2, &OpenAIAutoResetCreditState{ + AttemptCycleHash: "cycle-a", AttemptCreditHash: shortOpenAIAutoResetHash("missing"), + }, "cycle-a") + require.Error(t, err, "模糊结果后原卡消失时不得切换下一张卡") +} + +func TestOpenAIQuotaAutoResetService_AssessesIndependentWindows(t *testing.T) { + service := &OpenAIQuotaAutoResetService{} + account := &Account{Extra: map[string]any{ + "auto_pause_5h_disabled": true, + "auto_pause_7d_disabled": true, + }} + config := OpenAIAutoResetCreditConfig{Enabled: true, Threshold5h: 0.8, Threshold7d: 0.9} + tests := []struct { + name string + fiveHour float64 + sevenDay float64 + wantWindow string + }{ + {name: "5h", fiveHour: 0.8, sevenDay: 0.2, wantWindow: "5h"}, + {name: "7d", fiveHour: 0.2, sevenDay: 0.9, wantWindow: "7d"}, + {name: "同时触发", fiveHour: 0.95, sevenDay: 0.95, wantWindow: "5h+7d"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assessment := service.buildAssessment(account, config, test.fiveHour, test.sevenDay) + require.True(t, assessment.resetReached) + require.Equal(t, test.wantWindow, assessment.triggerWindow) + }) + } +} + +type autoResetTestAccountRepo struct { + AccountRepository + mu sync.Mutex + account *Account +} + +func (r *autoResetTestAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := *r.account + copy.Extra = cloneOpenAIAutoResetExtra(r.account.Extra) + return ©, nil +} + +func (r *autoResetTestAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.account.Extra == nil { + r.account.Extra = make(map[string]any) + } + for key, value := range updates { + r.account.Extra[key] = value + } + return nil +} + +type autoResetTestQuota struct { + usage *OpenAIQuotaUsage + resetCalls atomic.Int32 + resetEntered chan struct{} + releaseReset chan struct{} + enterOnce sync.Once + mu sync.Mutex + resetArgs [][2]string + failFirst bool +} + +func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) { + copy := *q.usage + return ©, nil +} + +func (q *autoResetTestQuota) CacheResetCreditsSnapshot(context.Context, int64, *OpenAIRateLimitResetCredits) error { + return nil +} + +func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + if creditID == "" || redeemRequestID == "" { + panic("targeted reset identifiers must be present") + } + call := q.resetCalls.Add(1) + q.mu.Lock() + q.resetArgs = append(q.resetArgs, [2]string{creditID, redeemRequestID}) + q.mu.Unlock() + if q.failFirst && call == 1 { + return nil, context.DeadlineExceeded + } + if q.resetEntered != nil { + q.enterOnce.Do(func() { close(q.resetEntered) }) + } + if q.releaseReset != nil { + <-q.releaseReset + } + return &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 2}, nil +} + +type autoResetTestRecoverer struct{} + +func (autoResetTestRecoverer) RecoverAccountState(context.Context, int64, AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) { + return &SuccessfulTestRecoveryResult{ClearedRateLimit: true}, nil +} + +func TestOpenAIQuotaAutoResetService_ConcurrentInstancesConsumeOnce(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 99, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_7d_used_percent": 10.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + "codex_7d_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + usage := &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 10, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 86400, ResetAt: now.Add(24 * time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "credit-sensitive-id", ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + } + quota := &autoResetTestQuota{usage: usage, resetEntered: make(chan struct{}), releaseReset: make(chan struct{})} + idempotencyRepo := newInMemoryIdempotencyRepo() + config := DefaultIdempotencyConfig() + config.ObserveOnly = false + config.ProcessingTimeout = time.Second + serviceA := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + serviceB := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = serviceA.evaluateAccount(context.Background(), account.ID) + }() + <-quota.resetEntered + go func() { + defer wg.Done() + _ = serviceB.evaluateAccount(context.Background(), account.ID) + }() + time.Sleep(50 * time.Millisecond) + close(quota.releaseReset) + wg.Wait() + + require.Equal(t, int32(1), quota.resetCalls.Load()) + repo.mu.Lock() + state := openAIAutoResetStateFromExtra(repo.account.Extra) + repo.mu.Unlock() + require.NotNil(t, state) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + encodedState, err := json.Marshal(state) + require.NoError(t, err) + require.NotContains(t, string(encodedState), "credit-sensitive-id") +} + +func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + expiresAt := now.Add(48 * time.Hour).Format(time.RFC3339) + quota := &autoResetTestQuota{ + failFirst: true, + usage: &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: expiresAt}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "retry-credit", ExpiresAt: expiresAt}}, + }, + } + idempotencyConfig := DefaultIdempotencyConfig() + idempotencyConfig.ObserveOnly = false + idempotencyConfig.FailedRetryBackoff = 0 + service := NewOpenAIQuotaAutoResetService( + repo, + quota, + autoResetTestRecoverer{}, + NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), + nil, nil, nil, + ) + + require.Error(t, service.evaluateAccount(context.Background(), account.ID)) + require.NoError(t, service.evaluateAccount(context.Background(), account.ID)) + quota.mu.Lock() + args := append([][2]string(nil), quota.resetArgs...) + quota.mu.Unlock() + require.Len(t, args, 2) + require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id") +} diff --git a/backend/internal/service/openai_quota_reset_credits.go b/backend/internal/service/openai_quota_reset_credits.go index 9ff7e70f16..b87e3fd0d1 100644 --- a/backend/internal/service/openai_quota_reset_credits.go +++ b/backend/internal/service/openai_quota_reset_credits.go @@ -8,6 +8,9 @@ import ( ) type openAIRateLimitResetCreditDetailPayload struct { + ID string `json:"id,omitempty"` + CreditID string `json:"credit_id,omitempty"` + CreditIDCamel string `json:"creditId,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` ExpiresAtCamel string `json:"expiresAt,omitempty"` ResetType string `json:"reset_type,omitempty"` @@ -29,6 +32,14 @@ type openAIRateLimitResetCreditDetails struct { AvailableCreditCount int CreditListPresent bool Credits []OpenAIRateLimitResetCreditDetail + AutoResetCandidates []openAIAutoResetCreditCandidate +} + +// openAIAutoResetCreditCandidate 仅在服务内部流转。上游卡 ID 不进入 API DTO、 +// 账号 extra 或日志,避免管理端响应扩大敏感标识暴露面。 +type openAIAutoResetCreditCandidate struct { + ID string + ExpiresAt string } func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCreditDetails, error) { @@ -64,6 +75,7 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr } credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits)) + autoResetCandidates := make([]openAIAutoResetCreditCandidate, 0, len(rawCredits)) availableCreditCount := 0 for _, raw := range rawCredits { if raw == nil { @@ -88,12 +100,24 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr continue } credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt}) + creditID := strings.TrimSpace(raw.ID) + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditID) + } + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditIDCamel) + } + autoResetCandidates = append(autoResetCandidates, openAIAutoResetCreditCandidate{ + ID: creditID, + ExpiresAt: expiresAt, + }) } return openAIRateLimitResetCreditDetails{ AvailableCount: availableCount, AvailableCreditCount: availableCreditCount, CreditListPresent: creditListPresent, Credits: credits, + AutoResetCandidates: autoResetCandidates, }, nil } diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 34f87f9a2f..2deb01ed89 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -14,8 +14,8 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t "availableCount":"2", "credits":[ {"reset_type":"codex_rate_limits","status":"redeemed","expires_at":"2026-07-01T04:05:06Z"}, - {"reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, - {"resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, + {"id":"credit-later","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, + {"creditId":"credit-earlier","resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, {"reset_type":"other","status":"available","expires_at":"2026-07-02T04:05:06Z"} ] }`) @@ -28,6 +28,10 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t {ExpiresAt: "2026-07-04T04:05:06Z"}, {ExpiresAt: "2026-07-03T04:05:06Z"}, }, details.Credits) + require.Equal(t, []openAIAutoResetCreditCandidate{ + {ID: "credit-later", ExpiresAt: "2026-07-04T04:05:06Z"}, + {ID: "credit-earlier", ExpiresAt: "2026-07-03T04:05:06Z"}, + }, details.AutoResetCandidates) } func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { diff --git a/backend/internal/service/openai_quota_reset_workflow.go b/backend/internal/service/openai_quota_reset_workflow.go new file mode 100644 index 0000000000..430b29618b --- /dev/null +++ b/backend/internal/service/openai_quota_reset_workflow.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "log/slog" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" + OpenAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" + OpenAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" +) + +type openAIQuotaResetWorkflowQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error +} + +type openAIQuotaResetWorkflowRecoverer interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaResetPostProcessResult 汇总手动和自动用卡后的共享恢复结果。 +// WarningCode 允许上游消费成功但本地恢复部分失败时仍然准确呈现状态。 +type OpenAIQuotaResetPostProcessResult struct { + Quota *OpenAIQuotaUsage + Account *Account + CacheRefreshed bool + AccountStateRecovered bool + WarningCode string +} + +// RunOpenAIQuotaResetPostProcess 按“解除限流、刷新额度缓存、刷新账号行”的固定顺序 +// 执行消费后恢复,避免手动和自动入口在失败语义上逐渐分叉。 +func RunOpenAIQuotaResetPostProcess( + ctx context.Context, + accountID int64, + quota openAIQuotaResetWorkflowQuota, + recoverer openAIQuotaResetWorkflowRecoverer, + loadAccount func(context.Context, int64) (*Account, error), +) OpenAIQuotaResetPostProcessResult { + result := OpenAIQuotaResetPostProcessResult{} + if recoverer == nil { + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + if _, err := recoverer.RecoverAccountState(ctx, accountID, AccountRecoveryOptions{InvalidateToken: true}); err != nil { + slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + result.AccountStateRecovered = true + + if quota != nil { + usage, usageErr := quota.QueryUsage(ctx, accountID) + switch { + case usageErr != nil || usage == nil: + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(usageErr)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + default: + if err := quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + } else { + result.Quota = usage + result.CacheRefreshed = true + } + } + } + + if loadAccount == nil { + return result + } + account, err := loadAccount(ctx, accountID) + if err != nil { + slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + if result.WarningCode == "" { + result.WarningCode = OpenAIQuotaResetWarningAccountRefreshFailed + } + return result + } + result.Account = account + return result +} diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 58bf4659f5..6e40c497fc 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -87,6 +87,7 @@ type OpenAIQuotaUsage struct { AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` FetchedAt int64 `json:"fetched_at"` + autoResetCandidates []openAIAutoResetCreditCandidate } // OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the @@ -180,6 +181,10 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* continue } status := resp.StatusCode + if isOpenAIAutoResetContext(ctx) { + slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "source", "auto_reset") + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(ctx, accountID, resp.String()), 240) slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) @@ -190,6 +195,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* payload.FetchedAt = time.Now().Unix() details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID) if details != nil { + payload.autoResetCandidates = details.AutoResetCandidates hasDetailCount := details.AvailableCount != nil if payload.RateLimitResetCredits == nil { payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{} @@ -274,6 +280,25 @@ func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for // idempotency. Returns the consumed credit metadata so the UI can refresh. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { + redeemRequestID, err := generateRedeemRequestID() + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) + } + return s.resetCredit(ctx, accountID, "", redeemRequestID, false) +} + +// ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时 +// 复用同一组参数;本方法不会回退到不带 credit_id 的旧消费方式。 +func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + creditID = strings.TrimSpace(creditID) + redeemRequestID = strings.TrimSpace(redeemRequestID) + if creditID == "" || redeemRequestID == "" { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required") + } + return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true) +} + +func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) { // Shadow guard: resetting credits via a shadow account would silently // operate on the parent's quota; that is surprising and unwanted. Callers // must reset the parent account directly. @@ -297,11 +322,6 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, err } - redeemRequestID, err := generateRedeemRequestID() - if err != nil { - return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) - } - client, err := s.privacyClientFactory(proxyURL) if err != nil { return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err) @@ -318,10 +338,14 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr) } headers["content-type"] = "application/json" + body := map[string]string{"redeem_request_id": redeemRequestID} + if targeted { + body["credit_id"] = creditID + } resp, err := client.R(). SetContext(callCtx). SetHeaders(headers). - SetBody(map[string]string{"redeem_request_id": redeemRequestID}). + SetBody(body). SetSuccessResult(&payload). Post(chatGPTRateLimitResetURL) if err != nil { @@ -336,6 +360,10 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( continue } status := resp.StatusCode + if targeted { + slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status) + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(callCtx, accountID, resp.String()), 240) slog.Warn("openai_quota_reset_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index a351381cb4..cb47a0c0c5 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -189,6 +189,32 @@ func TestResetCreditShadowRejected(t *testing.T) { "shadow ResetCredit 应映射为 409 Conflict 而非 500") } +func TestResetCreditTargetedSendsStableCreditAndRedeemIDs(t *testing.T) { + account := &Account{ + ID: 203, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "account-targeted"}, + } + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}} + tokenCache := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(account): "fake-token"}} + tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil) + var body map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/backend-api/wham/rate-limit-reset-credits/consume", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"code":"ok","windows_reset":1}`)) + })) + defer srv.Close() + + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + result, err := svc.ResetCreditTargeted(context.Background(), account.ID, "credit-123", "redeem-456") + require.NoError(t, err) + require.Equal(t, "ok", result.Code) + require.Equal(t, map[string]string{ + "credit_id": "credit-123", "redeem_request_id": "redeem-456", + }, body) +} + func TestResetCreditAgentIdentityUsesAssertionAndRecoversInvalidTaskOnce(t *testing.T) { _, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 2c42b810b6..f5ded9ccf0 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1092,6 +1092,9 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head // 单影子场景直接变成无可用账号(外审第8轮 P1)。整段跳过;影子的 codex_* 仅由 account_usage 的 // QueryUsage→persistOpenAICodexProbeSnapshot 维护,枯竭由调度守卫处理。 if account.IsShadow() { + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } return } // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, @@ -1105,6 +1108,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head if account.Platform == PlatformOpenAI { persistOpenAI429PlanType(ctx, s.accountRepo, account, responseBody) s.persistOpenAICodexSnapshot(ctx, account, headers) + notifyOpenAIAutoReset(account.ID) if resetAt := s.calculateOpenAI429ResetTime(headers); resetAt != nil { s.notifyAccountSchedulingBlocked(account, *resetAt, "429") if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { @@ -1620,7 +1624,9 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou } if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { slog.Warn("openai_codex_snapshot_persist_failed", "account_id", account.ID, "error", err) + return } + notifyOpenAIAutoReset(account.ID) } // parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3e469a95c5..a3e8cb935c 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -189,6 +189,29 @@ func ProvideOpenAIQuotaService( return service } +// ProvideOpenAIQuotaAutoResetService 启动账号级自动用卡队列与补偿扫描。 +func ProvideOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quotaService *OpenAIQuotaService, + rateLimitService *RateLimitService, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settingService *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + service := NewOpenAIQuotaAutoResetService( + accountRepo, + quotaService, + rateLimitService, + idempotency, + audit, + settingService, + leaderLock, + ) + service.Start() + return service +} + func ProvideAccountUsageService( accountRepo AccountRepository, usageLogRepo UsageLogRepository, @@ -842,6 +865,7 @@ var ProviderSet = wire.NewSet( ProvideGrokTokenProvider, ProvideOpenAITokenProvider, ProvideOpenAIQuotaService, + ProvideOpenAIQuotaAutoResetService, ProvideGrokQuotaService, ProvideCNProviderQuotaService, ProvideCNProviderBalanceService, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index d1cb92df59..e93d4726e1 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2266,6 +2266,66 @@ +
+
+
+ +

+ {{ t('admin.accounts.autoResetCredit.hint') }} +

+
+ +
+
+
+ + +
+
+ + +
+
+

{{ t('admin.accounts.autoResetCredit.thresholdHint') }}

+
+
(null) const autoPause7dThreshold = ref(null) const autoPause5hDisabled = ref(false) const autoPause7dDisabled = ref(false) +const autoResetCreditEnabled = ref(false) +const autoResetCredit5hThreshold = ref(100) +const autoResetCredit7dThreshold = ref(100) const upstreamBillingAutoProbeEnabled = ref(false) const upstreamBillingRateSyncEnabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling @@ -3626,6 +3689,11 @@ const syncFormFromAccount = (newAccount: Account | null) => { autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true + autoResetCreditEnabled.value = extra?.auto_reset_credit_enabled === true + autoResetCredit5hThreshold.value = + typeof extra?.auto_reset_credit_5h_threshold === 'number' ? extra.auto_reset_credit_5h_threshold * 100 : 100 + autoResetCredit7dThreshold.value = + typeof extra?.auto_reset_credit_7d_threshold === 'number' ? extra.auto_reset_credit_7d_threshold * 100 : 100 upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true upstreamBillingRateSyncEnabled.value = upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true @@ -4518,6 +4586,13 @@ const handleSubmit = async () => { appStore.showError(t('admin.accounts.pleaseSelectStatus')) return } + if (autoResetCreditEnabled.value) { + const thresholds = [autoResetCredit5hThreshold.value, autoResetCredit7dThreshold.value] + if (thresholds.some((value) => !Number.isFinite(value) || value < 0.1 || value > 100)) { + appStore.showError(t('admin.accounts.autoResetCredit.thresholdInvalid')) + return + } + } const updatePayload: Record = { ...form } try { @@ -5082,6 +5157,13 @@ const handleSubmit = async () => { } else { delete newExtra.auto_pause_7d_disabled } + if (props.account.type === 'oauth' && !isSparkShadow.value) { + newExtra.auto_reset_credit_enabled = autoResetCreditEnabled.value + newExtra.auto_reset_credit_5h_threshold = autoResetCredit5hThreshold.value / 100 + newExtra.auto_reset_credit_7d_threshold = autoResetCredit7dThreshold.value / 100 + } + // 运行态只允许后端服务更新,账号编辑不得回写旧状态。 + delete newExtra.codex_auto_reset_credit_state delete newExtra.codex_image_generation_bridge_enabled switch (codexImageToolMode.value) { diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index cb81bcfd9d..6a3225b8bf 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -63,6 +63,32 @@
+
+ + {{ autoResetStateLabel }} + + {{ autoResetState.trigger_window }} + + + + {{ formatResetCreditExpiry(autoResetState.checked_at, 'short') }} + + + {{ autoResetState.error_code }} + +
+
(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +type AutoResetCreditState = NonNullable['codex_auto_reset_credit_state']> +const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed']) +const autoResetState = computed(() => { + if (props.account.extra?.auto_reset_credit_enabled !== true) return null + const state = props.account.extra?.codex_auto_reset_credit_state + if (!state || typeof state !== 'object' || !validAutoResetStatuses.has(String(state.status))) return null + return state +}) +const autoResetStateLabel = computed(() => { + if (!autoResetState.value?.status) return '' + const keyByStatus: Record = { + checking: 'checking', + available: 'available', + resetting: 'resetting', + success: 'success', + no_credit: 'noCredit', + failed: 'failed' + } + return t(`admin.accounts.openaiQuotaReset.autoStatus.${keyByStatus[autoResetState.value.status]}`) +}) +const autoResetStateClass = computed(() => { + switch (autoResetState.value?.status) { + case 'available': + return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' + case 'success': + return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' + case 'no_credit': + case 'failed': + return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300' + case 'resetting': + return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' + default: + return 'bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-gray-300' + } +}) + // Rehydrate the card from the persisted snapshot. Credits that already expired // are dropped and the count is clamped to what remains: the snapshot has no // freshness signal, so an unfiltered read would offer to consume credits that no diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index acd0436f6a..eceb670c92 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -290,6 +290,18 @@ function buildOpenAISetupTokenAccount() { } as any } +function buildOpenAIOAuthParentAccount() { + return { + ...buildAccount(), + id: 7, + name: 'OpenAI OAuth Parent', + type: 'oauth', + parent_account_id: null, + credentials: { access_token: 'oauth-token' }, + extra: {} + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -1351,3 +1363,63 @@ describe('EditAccountModal', () => { ) }) }) + +describe('EditAccountModal OpenAI 自动使用重置卡', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + }) + + it('仅对 OpenAI OAuth 母账号显示,默认关闭且阈值为 100/100', () => { + const parent = mountModal(buildOpenAIOAuthParentAccount()) + expect(parent.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(true) + expect((parent.get('[data-testid="auto-reset-credit-5h-threshold"]').element as HTMLInputElement).value).toBe('100') + expect((parent.get('[data-testid="auto-reset-credit-7d-threshold"]').element as HTMLInputElement).value).toBe('100') + expect(parent.get('[data-testid="auto-reset-credit-5h-threshold"]').attributes('disabled')).toBeDefined() + parent.unmount() + + for (const account of [buildAccount(), buildOpenAISetupTokenAccount(), buildOpenAISparkShadowAccount()]) { + const wrapper = mountModal(account) + expect(wrapper.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(false) + wrapper.unmount() + } + }) + + it('独立保存两个阈值,并禁止把运行态回写到管理请求', async () => { + const account = buildOpenAIOAuthParentAccount() + account.extra = { + codex_auto_reset_credit_state: { + status: 'success', + trigger_window: '5h', + available_count: 1 + } + } + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click') + await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('75.5') + await wrapper.get('[data-testid="auto-reset-credit-7d-threshold"]').setValue('92') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + const extra = updateAccountMock.mock.calls[0]?.[1]?.extra + expect(extra).toMatchObject({ + auto_reset_credit_enabled: true, + auto_reset_credit_5h_threshold: 0.755, + auto_reset_credit_7d_threshold: 0.92 + }) + expect(extra).not.toHaveProperty('codex_auto_reset_credit_state') + wrapper.unmount() + }) + + it('开启后拒绝超出 0.1–100 范围的任一阈值', async () => { + const wrapper = mountModal(buildOpenAIOAuthParentAccount()) + await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click') + await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('0') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock).not.toHaveBeenCalled() + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index f888cbee44..280725bc5b 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -344,3 +344,45 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { wrapper.unmount() }) }) + +describe('OpenAIQuotaResetCell 自动用卡运行态', () => { + it.each([ + ['checking', 'checking'], + ['available', 'available'], + ['resetting', 'resetting'], + ['success', 'success'], + ['no_credit', 'noCredit'], + ['failed', 'failed'], + ] as const)('展示 %s 状态且不需要暴露卡标识', (status, labelKey) => { + const account = makeAccount({ + extra: { + auto_reset_credit_enabled: true, + codex_auto_reset_credit_state: { + status, + trigger_window: '5h', + available_count: 1, + checked_at: '2099-07-03T04:05:06Z', + error_code: status === 'failed' ? 'RESET_FAILED' : undefined, + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + const state = wrapper.get('[data-testid="auto-reset-credit-state"]') + expect(state.text()).toContain(`admin.accounts.openaiQuotaReset.autoStatus.${labelKey}`) + expect(state.text()).toContain('5h') + expect(state.text()).not.toContain('credit_id') + wrapper.unmount() + }) + + it('开关关闭时不显示历史运行态', () => { + const account = makeAccount({ + extra: { + auto_reset_credit_enabled: false, + codex_auto_reset_credit_state: { status: 'success', available_count: 1 }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + expect(wrapper.find('[data-testid="auto-reset-credit-state"]').exists()).toBe(false) + wrapper.unmount() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 3c126b78b0..9a2b1739b0 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -821,6 +821,14 @@ export default { autoPause5hDisabled: 'Disable 5h auto-pause', autoPause7dDisabled: 'Disable 7d auto-pause', autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).', + autoResetCredit: { + title: 'Automatically use reset credits', + hint: 'Uses the earliest-expiring available credit only when actual usage reaches a threshold. Off by default; the account remains paused if no credit is available or reset fails.', + threshold5h: '5h auto-reset threshold (%)', + threshold7d: '7d auto-reset threshold (%)', + thresholdHint: 'Each window is evaluated independently. Enter 0.1–100; both default to 100.', + thresholdInvalid: 'Automatic reset-credit thresholds must be between 0.1% and 100%.' + }, // Quota control (Anthropic OAuth/SetupToken only) quotaControl: { title: 'Quota Control', @@ -1515,6 +1523,14 @@ export default { resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.', resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.', refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.', + autoStatus: { + checking: 'Checking', + available: 'Credit available', + resetting: 'Auto-resetting', + success: 'Auto-reset succeeded', + noCredit: 'No credit', + failed: 'Auto-reset failed' + }, confirmTitle: 'Confirm Weekly Limit Reset', confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?' }, diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 923776d259..4c44d93392 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -486,6 +486,14 @@ export default { resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。', resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。', refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。', + autoStatus: { + checking: '检测中', + available: '卡可用', + resetting: '自动重置中', + success: '自动重置成功', + noCredit: '无卡', + failed: '自动重置失败' + }, confirmTitle: '确认重置周限', confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?' }, @@ -885,6 +893,14 @@ export default { autoPause5hDisabled: '禁用 5h 自动暂停', autoPause7dDisabled: '禁用 7d 自动暂停', autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。', + autoResetCredit: { + title: '自动使用重置卡', + hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。', + threshold5h: '5h 自动用卡阈值(%)', + threshold7d: '7d 自动用卡阈值(%)', + thresholdHint: '两个窗口独立判断,任一达到自身阈值即触发。可填写 0.1–100,默认均为 100。', + thresholdInvalid: '自动使用重置卡阈值必须在 0.1% 到 100% 之间。' + }, // Quota control (Anthropic OAuth/SetupToken only) quotaControl: { title: '配额控制', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index df6075c5c9..10a72a0a17 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1134,6 +1134,17 @@ export interface Account { available_count?: number credits?: { expires_at?: string }[] } + auto_reset_credit_enabled?: boolean + auto_reset_credit_5h_threshold?: number + auto_reset_credit_7d_threshold?: number + codex_auto_reset_credit_state?: { + status?: 'checking' | 'available' | 'resetting' | 'success' | 'no_credit' | 'failed' + trigger_window?: string + available_count?: number + checked_at?: string + last_result_at?: string + error_code?: string + } } & Record) proxy_id: number | null proxy_fallback_origin_id?: number | null From 96b160d9a0c07121f87ebce4a313374c0ad711c6 Mon Sep 17 00:00:00 2001 From: NellPoi Date: Mon, 24 Aug 2026 13:46:19 +0800 Subject: [PATCH 47/64] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=85=B1=E4=BA=AB=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E7=A0=81=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/openai_oauth_handler.go | 8 -------- .../admin/openai_oauth_handler_reset_quota_test.go | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index ce83d4bfe8..779e4455cd 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -41,14 +41,6 @@ type openAIAccountStateRecoverer interface { // it — spending a second credit. const openAIQuotaResetPostProcessTimeout = 8 * time.Second -// 保留 handler 包内的告警码名称,避免共享工作流下沉到 service 后破坏既有测试和包内调用。 -// 实际值统一引用 service 常量,防止手动重置与自动重置的响应码发生漂移。 -const ( - openAIQuotaResetWarningCacheRefreshFailed = service.OpenAIQuotaResetWarningCacheRefreshFailed - openAIQuotaResetWarningAccountRecoveryFailed = service.OpenAIQuotaResetWarningAccountRecoveryFailed - openAIQuotaResetWarningAccountRefreshFailed = service.OpenAIQuotaResetWarningAccountRefreshFailed -) - type openAIQuotaResetResponse struct { service.OpenAIQuotaResetResult Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"` diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go index c0e3ca161d..b9e8dac679 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -215,7 +215,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -237,7 +237,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.Zero(t, quota.queryCalls) require.Zero(t, adminService.calls) @@ -260,7 +260,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -285,7 +285,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -307,7 +307,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.CacheRefreshed) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Quota) @@ -331,7 +331,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.Nil(t, envelope.Data.Account) } @@ -435,7 +435,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Account) require.Zero(t, quota.cacheCalls) From 847c0c45263b8823b32c0f8c34387e1f7a6c1f3b Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Mon, 24 Aug 2026 14:20:00 +0800 Subject: [PATCH 48/64] feat(gateway): configure model list read limit Add gateway.models_list_read_max_bytes with the existing 8 MiB behavior as its default, and apply it consistently to generic, Codex, and Antigravity model-list reads. Read one sentinel byte for Codex manifests so oversized responses return an explicit bounded upstream error instead of malformed JSON. --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/config/config.go | 10 ++++ backend/internal/config/config_test.go | 12 +++++ backend/internal/pkg/antigravity/client.go | 13 +++--- .../internal/pkg/antigravity/client_test.go | 46 +++++++++++++++---- .../service/antigravity_quota_fetcher.go | 8 ++-- .../service/antigravity_quota_fetcher_test.go | 35 ++++++++++++++ .../service/models_list_response_limit.go | 10 ++++ .../models_list_response_limit_test.go | 25 ++++++++++ .../service/openai_codex_models_service.go | 20 +++++--- .../openai_codex_models_service_test.go | 46 +++++++++++++++++++ backend/internal/service/upstream_models.go | 16 ++++--- .../internal/service/upstream_models_test.go | 25 ++++++++++ deploy/config.example.yaml | 3 ++ 14 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 backend/internal/service/models_list_response_limit.go create mode 100644 backend/internal/service/models_list_response_limit_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1300989dc6..5ac2ba5823 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -194,7 +194,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) - antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) + antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b8218d1824..8c856cbf61 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -61,6 +61,10 @@ const ( // 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。 const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024 +// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。 +// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。 +const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024 + type Config struct { Server ServerConfig `mapstructure:"server"` Log LogConfig `mapstructure:"log"` @@ -960,6 +964,8 @@ type GatewayConfig struct { TextMaxBodySize int64 `mapstructure:"text_max_body_size"` // 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大 UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"` + // 上游模型列表响应体读取上限(字节) + ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"` // 代理探测响应体读取上限(字节) ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"` // Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销) @@ -2456,6 +2462,7 @@ func setDefaults() { viper.SetDefault("gateway.max_body_size", int64(256*1024*1024)) viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024)) viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes) + viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes) viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024)) viper.SetDefault("gateway.gemini_debug_response_headers", false) viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy) @@ -3265,6 +3272,9 @@ func (c *Config) Validate() error { if c.Gateway.UpstreamResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive") } + if c.Gateway.ModelsListReadMaxBytes <= 0 { + return fmt.Errorf("gateway.models_list_read_max_bytes must be positive") + } if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 7cef4dfc1f..a8fbba0977 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -23,6 +23,13 @@ func resetViperWithJWTSecret(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } +func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) +} + func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string @@ -1803,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 }, wantErr: "gateway.text_max_body_size", }, + { + name: "gateway models list read limit", + mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 }, + wantErr: "gateway.models_list_read_max_bytes", + }, { name: "gateway response header timeout", mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 }, diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 785c8af3c4..a3ea6709f7 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -255,8 +255,6 @@ const ( proxyTLSHandshakeTimeout = 5 * time.Second // clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body) clientTimeout = 10 * time.Second - // fetchAvailableModelsBodyLimit limits model-list responses to avoid unbounded memory use. - fetchAvailableModelsBodyLimit int64 = 8 << 20 ) func NewClient(proxyURL string) (*Client, error) { @@ -656,10 +654,13 @@ type FetchAvailableModelsResponse struct { // FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON // 支持 URL fallback:sandbox → daily → prod -func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) { +func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) { if c == nil || c.httpClient == nil { return nil, nil, errors.New("antigravity client is not configured") } + if bodyLimit <= 0 { + return nil, nil, errors.New("fetchAvailableModels body limit must be positive") + } reqBody := FetchAvailableModelsRequest{Project: projectID} bodyBytes, err := json.Marshal(reqBody) @@ -693,13 +694,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI return nil, nil, lastErr } - respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, fetchAvailableModelsBodyLimit+1)) + respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) _ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏 if err != nil { return nil, nil, fmt.Errorf("读取响应失败: %w", err) } - if int64(len(respBodyBytes)) > fetchAvailableModelsBodyLimit { - return nil, nil, fmt.Errorf("响应超过 %d 字节", fetchAvailableModelsBodyLimit) + if int64(len(respBodyBytes)) > bodyLimit { + return nil, nil, fmt.Errorf("响应超过 %d 字节", bodyLimit) } // 检查是否需要 URL 降级 diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index b6c2e6a502..7a525879fa 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -15,6 +15,8 @@ import ( "time" ) +const defaultFetchAvailableModelsBodyLimit int64 = 8 << 20 + // --------------------------------------------------------------------------- // NewAPIRequestWithURL // --------------------------------------------------------------------------- @@ -1517,7 +1519,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1562,6 +1564,34 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { } } +func TestClient_FetchAvailableModels_RejectsNonPositiveBodyLimit(t *testing.T) { + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 0) + if err == nil || !strings.Contains(err.Error(), "body limit must be positive") { + t.Fatalf("非正读取上限应返回调用错误: %v", err) + } +} + +func TestClient_FetchAvailableModels_UsesConfiguredBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + withMockBaseURLs(t, []string{server.URL}) + + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 8) + if err == nil { + t.Fatal("响应超过配置上限时应返回错误") + } + if !strings.Contains(err.Error(), "响应超过 8 字节") { + t.Fatalf("错误应包含配置上限: %v", err) + } +} + func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -1572,7 +1602,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("服务器返回 403 时应返回错误") } @@ -1592,7 +1622,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("无效 JSON 响应应返回错误") } @@ -1622,7 +1652,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err) } @@ -1650,7 +1680,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("所有 URL 都失败时应返回错误") } @@ -1669,7 +1699,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := client.FetchAvailableModels(ctx, "token", "proj") + _, _, err := client.FetchAvailableModels(ctx, "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("context 取消时应返回错误") } @@ -1686,7 +1716,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1748,7 +1778,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err) } diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go index 9e09c90443..e296654e6d 100644 --- a/backend/internal/service/antigravity_quota_fetcher.go +++ b/backend/internal/service/antigravity_quota_fetcher.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -28,11 +29,12 @@ const ( // AntigravityQuotaFetcher 从 Antigravity API 获取额度 type AntigravityQuotaFetcher struct { proxyRepo ProxyRepository + cfg *config.Config } // NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher -func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository) *AntigravityQuotaFetcher { - return &AntigravityQuotaFetcher{proxyRepo: proxyRepo} +func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher { + return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg} } // CanFetch 检查是否可以获取此账户的额度 @@ -55,7 +57,7 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou } // 调用 API 获取配额 - modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) + modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg)) if err != nil { // 403 Forbidden: 不报错,返回 is_forbidden 标记 var forbiddenErr *antigravity.ForbiddenError diff --git a/backend/internal/service/antigravity_quota_fetcher_test.go b/backend/internal/service/antigravity_quota_fetcher_test.go index e0f5705141..a5463ec007 100644 --- a/backend/internal/service/antigravity_quota_fetcher_test.go +++ b/backend/internal/service/antigravity_quota_fetcher_test.go @@ -3,11 +3,16 @@ package service import ( + "context" "errors" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -390,6 +395,36 @@ func TestBuildUsageInfo_AICredits(t *testing.T) { require.Equal(t, 5.0, info.AICredits[0].MinimumBalance) } +func TestFetchQuotaUsesConfiguredModelsListBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) + oldAvailability := antigravity.DefaultURLAvailability + t.Cleanup(func() { + antigravity.BaseURLs = oldBaseURLs + antigravity.DefaultURLAvailability = oldAvailability + }) + antigravity.BaseURLs = []string{server.URL} + antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) + + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 8 + fetcher := NewAntigravityQuotaFetcher(nil, cfg) + _, err := fetcher.FetchQuota(context.Background(), &Account{ + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + "project_id": "project", + }, + }, "") + require.ErrorContains(t, err, "响应超过 8 字节") +} + func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) { // 模拟 FetchQuota 遇到 403 时的行为: // FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true diff --git a/backend/internal/service/models_list_response_limit.go b/backend/internal/service/models_list_response_limit.go new file mode 100644 index 0000000000..b10260fd98 --- /dev/null +++ b/backend/internal/service/models_list_response_limit.go @@ -0,0 +1,10 @@ +package service + +import "github.com/Wei-Shaw/sub2api/internal/config" + +func resolveModelsListReadLimit(cfg *config.Config) int64 { + if cfg != nil && cfg.Gateway.ModelsListReadMaxBytes > 0 { + return cfg.Gateway.ModelsListReadMaxBytes + } + return config.DefaultModelsListReadMaxBytes +} diff --git a/backend/internal/service/models_list_response_limit_test.go b/backend/internal/service/models_list_response_limit_test.go new file mode 100644 index 0000000000..d697351510 --- /dev/null +++ b/backend/internal/service/models_list_response_limit_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestResolveModelsListReadLimit(t *testing.T) { + t.Run("nil config uses default", func(t *testing.T) { + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(nil)) + }) + + t.Run("configured limit wins", func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + require.Equal(t, int64(16<<20), resolveModelsListReadLimit(cfg)) + }) + + t.Run("non-positive limit uses default", func(t *testing.T) { + cfg := &config.Config{} + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(cfg)) + }) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 02279b5c0c..7c06dc99bd 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -27,12 +27,11 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestBodyLimit int64 = 8 << 20 - codexModelsManifestCacheBodyLimit = 1 << 20 - codexModelsManifestCacheMaxEntries = 64 - codexModelsManifestCacheTTL = 30 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute - codexModelsManifestRequestTimeout = 15 * time.Second + codexModelsManifestCacheBodyLimit = 1 << 20 + codexModelsManifestCacheMaxEntries = 64 + codexModelsManifestCacheTTL = 30 * time.Second + codexModelsManifestCacheStaleTTL = 5 * time.Minute + codexModelsManifestRequestTimeout = 15 * time.Second ) // CodexModelsManifest carries the client representation plus caching metadata. @@ -515,13 +514,20 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } - body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err), retryable: isRetryableCodexModelsManifestTransportError(err), } } + if int64(len(body)) > bodyLimit { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest response exceeds %d bytes", bodyLimit), + retryable: true, + } + } upstreamBody := body if request.useAPIKeyUpstream { body = convertOpenAIModelListToCodexManifest(body) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 8f4c495755..503d494857 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "errors" "fmt" @@ -623,6 +624,51 @@ func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { } } +func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) { + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + s.cfg.Gateway.ModelsListReadMaxBytes = 8 + _, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example"), + "0.144.0", + "", + ) + require.Error(t, err) + require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err)) + require.Contains(t, err.Error(), "response exceeds 8 bytes") + require.True(t, IsRetryableCodexModelsManifestError(err)) +} + +func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) { + manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}` + require.Greater(t, len(manifestBody), 8<<20) + require.Less(t, len(manifestBody), 16<<20) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, manifestBody) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{cfg: &config.Config{}} + s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "") + require.NoError(t, err) + require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact") +} + func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 3941794860..790b2243d2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -14,8 +14,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" ) -const upstreamModelsBodyLimit int64 = 8 << 20 - // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string @@ -101,12 +99,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } - if int64(len(body)) > upstreamModelsBodyLimit { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit)) + if int64(len(body)) > bodyLimit { + return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { @@ -506,7 +505,12 @@ func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) } - modelsResp, _, err := client.FetchAvailableModels(ctx, accessToken, strings.TrimSpace(account.GetCredential("project_id"))) + modelsResp, _, err := client.FetchAvailableModels( + ctx, + accessToken, + strings.TrimSpace(account.GetCredential("project_id")), + resolveModelsListReadLimit(s.cfg), + ) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index b6f3cb3f95..114c647d45 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -394,6 +394,31 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) { require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization")) } +func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)), + }} + cfg := upstreamModelSyncTestConfig() + cfg.Gateway.ModelsListReadMaxBytes = 8 + svc := &AccountTestService{httpUpstream: upstream, cfg: cfg} + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "openai-key", + "base_url": "https://openai.example.com/v1", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "response exceeds 8 bytes") +} + func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { t.Parallel() diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index a0dbfbf973..033cb29908 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -235,6 +235,9 @@ gateway: # Max bytes to read for non-stream upstream responses (default: 8MB) # 非流式上游响应体读取上限(默认 8MB) upstream_response_read_max_bytes: 8388608 + # Max bytes to read for upstream model-list responses (default: 8MB) + # 上游模型列表响应体读取上限(默认 8MB) + models_list_read_max_bytes: 8388608 # Max bytes to read for proxy probe responses (default: 1MB) # 代理探测响应体读取上限(默认 1MB) proxy_probe_response_read_max_bytes: 1048576 From 03e8ab41346b42de9ece4e3e5bfcb6ca2b8cb57e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:30:34 +0000 Subject: [PATCH 49/64] chore: sync VERSION to 0.1.180 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f4fdeb66be..2005625e83 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.179 +0.1.180 From 99ec347eaff944d255032565717053ad02d5f5f3 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 20 Aug 2026 22:45:35 +0800 Subject: [PATCH 50/64] fix(antigravity): migrate legacy Sonnet tests to 4.6 --- backend/internal/domain/constants.go | 6 +-- backend/internal/domain/constants_test.go | 14 ++++++ .../internal/service/account_test_service.go | 18 ++++--- .../account_test_service_antigravity_test.go | 16 ++++++ .../antigravity_credits_overages_test.go | 6 +-- .../service/antigravity_model_mapping_test.go | 12 ++--- .../service/antigravity_rate_limit_test.go | 50 ++++++++++++++++--- .../antigravity_single_account_retry_test.go | 4 +- .../internal/service/model_rate_limit_test.go | 8 +-- 9 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 backend/internal/service/account_test_service_antigravity_test.go diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index cd821165a4..6500502014 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -105,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射 "claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-6": "claude-sonnet-4-6", - "claude-sonnet-4-5": "claude-sonnet-4-5", - "claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking", + "claude-sonnet-4-5": "claude-sonnet-4-6", // 迁移旧模型 + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧模型 // Claude 详细版本 ID 映射 "claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型 - "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型 // Claude Haiku → Sonnet(无 Haiku 支持) "claude-haiku-4-5": "claude-sonnet-4-6", "claude-haiku-4-5-20251001": "claude-sonnet-4-6", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index e847e22f30..7cfcd7fa51 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -43,6 +43,20 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_MigratesLegacySonnet45(t *testing.T) { + t.Parallel() + + for _, model := range []string{ + "claude-sonnet-4-5", + "claude-sonnet-4-5-thinking", + "claude-sonnet-4-5-20250929", + } { + if got := DefaultAntigravityModelMapping[model]; got != "claude-sonnet-4-6" { + t.Fatalf("expected legacy model %q to map to claude-sonnet-4-6, got %q", model, got) + } + } +} + func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 451f90db16..e7ce083ff5 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -43,8 +43,9 @@ import ( var sseDataPrefix = regexp.MustCompile(`^data:\s*`) const ( - testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" - chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" + chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + defaultAntigravityTestModel = "claude-sonnet-4-6" ) // TestEvent represents a SSE event for account testing @@ -2311,11 +2312,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() - // 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview - testModelID := modelID - if testModelID == "" { - testModelID = "claude-sonnet-4-5" - } + testModelID := antigravityConnectionTestModel(modelID) if s.antigravityGatewayService == nil { return s.sendErrorAndEnd(c, "Antigravity gateway service not configured") @@ -2346,6 +2343,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac return nil } +func antigravityConnectionTestModel(modelID string) string { + if modelID == "" { + return defaultAntigravityTestModel + } + return modelID +} + // buildGeminiAPIKeyRequest builds request for Gemini API Key accounts func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { apiKey := account.GetCredential("api_key") diff --git a/backend/internal/service/account_test_service_antigravity_test.go b/backend/internal/service/account_test_service_antigravity_test.go new file mode 100644 index 0000000000..b3362f3f69 --- /dev/null +++ b/backend/internal/service/account_test_service_antigravity_test.go @@ -0,0 +1,16 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAntigravityConnectionTestModel(t *testing.T) { + t.Parallel() + + require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel("")) + require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview")) +} diff --git a/backend/internal/service/antigravity_credits_overages_test.go b/backend/internal/service/antigravity_credits_overages_test.go index 7a5224daf3..bdba5cec7b 100644 --- a/backend/internal/service/antigravity_credits_overages_test.go +++ b/backend/internal/service/antigravity_credits_overages_test.go @@ -239,7 +239,7 @@ func TestAntigravityRetryLoop_ModelRateLimited_InjectsCredits(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, @@ -290,7 +290,7 @@ func TestAntigravityRetryLoop_CreditsExhausted_DoesNotInject(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, @@ -355,7 +355,7 @@ func TestAntigravityRetryLoop_CreditErrorMarksExhausted(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 62ff2e295d..0f86aa4a9c 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -69,10 +69,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5", + name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-20250929", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, // 3. 默认映射中的透传(映射到自己) @@ -89,10 +89,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射透传 - claude-sonnet-4-5", + name: "默认映射 - claude-sonnet-4-5 → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, { name: "默认映射透传 - claude-opus-4-8", @@ -113,10 +113,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-opus-4-6-thinking", }, { - name: "默认映射透传 - claude-sonnet-4-5-thinking", + name: "默认映射 - claude-sonnet-4-5-thinking → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-thinking", accountMapping: nil, - expected: "claude-sonnet-4-5-thinking", + expected: "claude-sonnet-4-6", }, { name: "默认映射透传 - gemini-2.5-flash", diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index c9b4298291..7a06a4e49e 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -206,7 +206,7 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) { // 但 429 兜底逻辑会使用 requestedModel 设置模型级限流 require.Nil(t, result) require.Len(t, repo.modelRateLimitCalls, 1) - require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) + require.Equal(t, "claude-sonnet-4-6", repo.modelRateLimitCalls[0].modelKey) } // TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey 测试 429 非模型限流场景 @@ -844,17 +844,28 @@ func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) { require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey) } -func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.T) { +func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) { repo := &stubAntigravityAccountRepo{} svc := &AntigravityGatewayService{} - account := &Account{ID: 790, Platform: PlatformAntigravity} + account := &Account{ + ID: 790, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } resetAt := time.Now().Add(30 * time.Second) + canonicalModel := resolveFinalAntigravityModelKey(context.Background(), account, "custom-sonnet") + require.Equal(t, "claude-sonnet-4-5", canonicalModel) success := svc.setAntigravityModelRateLimits( context.Background(), repo, account, - "claude-sonnet-4-5", + canonicalModel, "[test]", 429, resetAt, @@ -866,6 +877,33 @@ func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) } +func TestSetModelRateLimitAndClearSession_UsesUpstreamReportedModelMetadata(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + account := &Account{ + ID: 791, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } + + svc.setModelRateLimitAndClearSession(&handleModelRateLimitParams{ + ctx: context.Background(), + prefix: "[test]", + account: account, + statusCode: 429, + }, &antigravitySmartRetryInfo{ + RetryDelay: 30 * time.Second, + ModelName: "claude-sonnet-4-5", + }) + + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) +} + func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { upstream := &recordingOKUpstream{} account := &Account{ @@ -877,7 +915,7 @@ func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": time.Now().Add(2 * time.Second).Format(time.RFC3339), }, }, @@ -920,7 +958,7 @@ func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRemainingLong(t *testing.T) { Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": time.Now().Add(11 * time.Second).Format(time.RFC3339), }, }, diff --git a/backend/internal/service/antigravity_single_account_retry_test.go b/backend/internal/service/antigravity_single_account_retry_test.go index 6e58ab75d2..85677e59c4 100644 --- a/backend/internal/service/antigravity_single_account_retry_test.go +++ b/backend/internal/service/antigravity_single_account_retry_test.go @@ -693,7 +693,7 @@ func TestAntigravityRetryLoop_PreCheck_SingleAccountRetry_SkipsRateLimit(t *test Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339), }, }, @@ -737,7 +737,7 @@ func TestAntigravityRetryLoop_PreCheck_NoSingleAccountRetry_SwitchesOnRateLimit( Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339), }, }, diff --git a/backend/internal/service/model_rate_limit_test.go b/backend/internal/service/model_rate_limit_test.go index 430d91baf6..3293853d74 100644 --- a/backend/internal/service/model_rate_limit_test.go +++ b/backend/internal/service/model_rate_limit_test.go @@ -255,7 +255,7 @@ func TestIsModelRateLimited_OpenAIImageGenerationIntentBlocksTextModelImageTool( require.True(t, account.isModelRateLimitedWithContext(WithOpenAIImageGenerationIntent(context.Background()), "gpt-5.4")) } -func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) { +func TestIsModelRateLimited_Antigravity_LegacySonnetUsesCanonicalModelKey(t *testing.T) { now := time.Now() future := now.Add(10 * time.Minute).Format(time.RFC3339) @@ -263,7 +263,7 @@ func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) { Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5-thinking": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": future, }, }, @@ -453,7 +453,7 @@ func TestGetRateLimitRemainingTime(t *testing.T) { Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": future15m, }, }, @@ -469,7 +469,7 @@ func TestGetRateLimitRemainingTime(t *testing.T) { Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ + "claude-sonnet-4-6": map[string]any{ "rate_limit_reset_at": future5m, }, }, From 71aa6e357448736105f24f4032836bb6e570ed65 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 24 Aug 2026 16:07:27 +0800 Subject: [PATCH 51/64] fix(antigravity): preserve explicit Sonnet 4.5 routing --- backend/internal/domain/constants.go | 4 ++-- backend/internal/domain/constants_test.go | 17 +++++++++-------- .../antigravity_credits_overages_test.go | 6 +++--- .../service/antigravity_model_mapping_test.go | 13 +++++++++++-- .../service/antigravity_rate_limit_test.go | 6 +++--- .../antigravity_single_account_retry_test.go | 4 ++-- .../internal/service/model_rate_limit_test.go | 8 ++++---- 7 files changed, 34 insertions(+), 24 deletions(-) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 6500502014..1ed90ee940 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -105,8 +105,8 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射 "claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-6": "claude-sonnet-4-6", - "claude-sonnet-4-5": "claude-sonnet-4-6", // 迁移旧模型 - "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧模型 + "claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传 + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名 // Claude 详细版本 ID 映射 "claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型 diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index 7cfcd7fa51..acac038ef2 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -43,16 +43,17 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { } } -func TestDefaultAntigravityModelMapping_MigratesLegacySonnet45(t *testing.T) { +func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) { t.Parallel() - for _, model := range []string{ - "claude-sonnet-4-5", - "claude-sonnet-4-5-thinking", - "claude-sonnet-4-5-20250929", - } { - if got := DefaultAntigravityModelMapping[model]; got != "claude-sonnet-4-6" { - t.Fatalf("expected legacy model %q to map to claude-sonnet-4-6, got %q", model, got) + cases := map[string]string{ + "claude-sonnet-4-5": "claude-sonnet-4-5", + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", + } + for model, want := range cases { + if got := DefaultAntigravityModelMapping[model]; got != want { + t.Fatalf("expected model %q to map to %q, got %q", model, want, got) } } } diff --git a/backend/internal/service/antigravity_credits_overages_test.go b/backend/internal/service/antigravity_credits_overages_test.go index bdba5cec7b..7a5224daf3 100644 --- a/backend/internal/service/antigravity_credits_overages_test.go +++ b/backend/internal/service/antigravity_credits_overages_test.go @@ -239,7 +239,7 @@ func TestAntigravityRetryLoop_ModelRateLimited_InjectsCredits(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, @@ -290,7 +290,7 @@ func TestAntigravityRetryLoop_CreditsExhausted_DoesNotInject(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, @@ -355,7 +355,7 @@ func TestAntigravityRetryLoop_CreditErrorMarksExhausted(t *testing.T) { Extra: map[string]any{ "allow_overages": true, modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limited_at": time.Now().UTC().Format(time.RFC3339), "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 0f86aa4a9c..61cc7e31d6 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -89,10 +89,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射 - claude-sonnet-4-5 → claude-sonnet-4-6", + name: "显式 canonical 选择 - claude-sonnet-4-5 透传", requestedModel: "claude-sonnet-4-5", accountMapping: nil, - expected: "claude-sonnet-4-6", + expected: "claude-sonnet-4-5", }, { name: "默认映射透传 - claude-opus-4-8", @@ -118,6 +118,15 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { accountMapping: nil, expected: "claude-sonnet-4-6", }, + { + name: "账户显式目标只映射一步 - custom-sonnet → claude-sonnet-4-5", + requestedModel: "custom-sonnet", + accountMapping: map[string]string{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + expected: "claude-sonnet-4-5", + }, { name: "默认映射透传 - gemini-2.5-flash", requestedModel: "gemini-2.5-flash", diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 7a06a4e49e..d632f14bda 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -206,7 +206,7 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) { // 但 429 兜底逻辑会使用 requestedModel 设置模型级限流 require.Nil(t, result) require.Len(t, repo.modelRateLimitCalls, 1) - require.Equal(t, "claude-sonnet-4-6", repo.modelRateLimitCalls[0].modelKey) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) } // TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey 测试 429 非模型限流场景 @@ -915,7 +915,7 @@ func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": time.Now().Add(2 * time.Second).Format(time.RFC3339), }, }, @@ -958,7 +958,7 @@ func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRemainingLong(t *testing.T) { Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": time.Now().Add(11 * time.Second).Format(time.RFC3339), }, }, diff --git a/backend/internal/service/antigravity_single_account_retry_test.go b/backend/internal/service/antigravity_single_account_retry_test.go index 85677e59c4..6e58ab75d2 100644 --- a/backend/internal/service/antigravity_single_account_retry_test.go +++ b/backend/internal/service/antigravity_single_account_retry_test.go @@ -693,7 +693,7 @@ func TestAntigravityRetryLoop_PreCheck_SingleAccountRetry_SkipsRateLimit(t *test Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339), }, }, @@ -737,7 +737,7 @@ func TestAntigravityRetryLoop_PreCheck_NoSingleAccountRetry_SwitchesOnRateLimit( Concurrency: 1, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": time.Now().Add(30 * time.Second).Format(time.RFC3339), }, }, diff --git a/backend/internal/service/model_rate_limit_test.go b/backend/internal/service/model_rate_limit_test.go index 3293853d74..430d91baf6 100644 --- a/backend/internal/service/model_rate_limit_test.go +++ b/backend/internal/service/model_rate_limit_test.go @@ -255,7 +255,7 @@ func TestIsModelRateLimited_OpenAIImageGenerationIntentBlocksTextModelImageTool( require.True(t, account.isModelRateLimitedWithContext(WithOpenAIImageGenerationIntent(context.Background()), "gpt-5.4")) } -func TestIsModelRateLimited_Antigravity_LegacySonnetUsesCanonicalModelKey(t *testing.T) { +func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) { now := time.Now() future := now.Add(10 * time.Minute).Format(time.RFC3339) @@ -263,7 +263,7 @@ func TestIsModelRateLimited_Antigravity_LegacySonnetUsesCanonicalModelKey(t *tes Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5-thinking": map[string]any{ "rate_limit_reset_at": future, }, }, @@ -453,7 +453,7 @@ func TestGetRateLimitRemainingTime(t *testing.T) { Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": future15m, }, }, @@ -469,7 +469,7 @@ func TestGetRateLimitRemainingTime(t *testing.T) { Platform: PlatformAntigravity, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ - "claude-sonnet-4-6": map[string]any{ + "claude-sonnet-4-5": map[string]any{ "rate_limit_reset_at": future5m, }, }, From e440ac48c7d7e2649bf3f1e062d2a3793f50b021 Mon Sep 17 00:00:00 2001 From: akihitohyh <79531840+akihitohyh@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:03:44 +0800 Subject: [PATCH 52/64] fix(openai): clear the rejected input status for the whole item type The upstream names one offending index per response, but a replayed conversation routinely carries dozens of items of the same type, each with a status the upstream schema does not accept. Clearing a single index per round trip needs one retry per item, so a conversation with more than maxOpenAIResponsesRejectedFieldRetries such items exhausts the bounded budget and the 400 reaches the client. Reported against tool_search_output items, where the rejection surfaced as "Unknown parameter: 'input[60].status'". Clear the status of every input item sharing the rejected item's type in the same pass. Items of other types keep theirs: the rejection only proves that the rejected item's type has no status field. When the rejected item carries no type to match on, fall back to clearing the named index alone. Co-Authored-By: Claude Opus 5 --- .../openai_responses_rejected_field_retry.go | 50 ++++++++++++++++--- ...nai_responses_rejected_field_retry_test.go | 40 +++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index 20affff9cf..f70bdd89ac 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -271,18 +271,56 @@ func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (in return 0, false } +// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the +// upstream rejected, and the status of every other input item sharing the +// rejected item's type. +// +// The upstream names one offending index per response, but a replayed +// conversation routinely carries dozens of items of the same type, each with a +// status its schema does not accept. Clearing one index per round trip would +// need one retry per item and exhaust the bounded retry budget long before the +// request could succeed. Items of other types keep their status: the rejection +// only proves that this type has no status field. func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) { itemPath := fmt.Sprintf("input.%d", index) - if !gjson.GetBytes(body, itemPath).IsObject() { + rejected := gjson.GetBytes(body, itemPath) + if !rejected.IsObject() { return nil, "", false, nil } - statusPath := itemPath + ".status" - if !gjson.GetBytes(body, statusPath).Exists() { + if !gjson.GetBytes(body, itemPath+".status").Exists() { return nil, "", false, nil } - retryBody, err := sjson.DeleteBytes(body, statusPath) - if err != nil { - return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err) + + retryBody := body + cleared := 0 + rejectedType := strings.TrimSpace(rejected.Get("type").String()) + if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() { + // Deleting a field never shifts array indexes, so positions read from + // the original body stay valid against the rewritten one. + for itemIndex, item := range input.Array() { + if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType { + continue + } + statusPath := fmt.Sprintf("input.%d.status", itemIndex) + if !gjson.GetBytes(retryBody, statusPath).Exists() { + continue + } + next, err := sjson.DeleteBytes(retryBody, statusPath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err) + } + retryBody = next + cleared++ + } + } + if cleared == 0 { + // The rejected item carries no type to match on; fall back to clearing + // just the index the upstream named. + next, err := sjson.DeleteBytes(retryBody, itemPath+".status") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err) + } + retryBody = next } return retryBody, "indexed status parameter rejection", true, nil } diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 96b118050c..9f794b1e57 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -681,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response Body: io.NopCloser(strings.NewReader(body)), } } + +// A replayed conversation carries many items of the same type, each with a +// status the upstream schema rejects. One rejection must clear all of them: +// clearing one index per round trip exhausts the bounded retry budget. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) { + input := make([]string, 0, 12) + for i := 0; i < 10; i++ { + input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`) + } + input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`) + body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`) + + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`) + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, reason) + + for i := 0; i < 10; i++ { + require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(), + "every tool_search_output must lose its status in a single retry, index %d did not", i) + require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(), + "unrelated fields must survive") + } + require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(), + "a different item type keeps its status: the rejection only proves this type has none") +} + +// The rejected item may carry no type to match on. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) { + body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists()) +} From 1563db3f82a4f44a576898c4662e8f1f4b022307 Mon Sep 17 00:00:00 2001 From: 759502416 <38681911+759502416@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:15:39 +0800 Subject: [PATCH 53/64] fix(openai): keep parallel_tool_calls for Responses Lite additional_tools normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level "tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type "additional_tools" and deletes the top-level key. A Responses Lite request that carries tools therefore looks like it has none, and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls had just pinned gets deleted on the way out. OpenAI then applies its default of true and rejects the request: 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires `parallel_tool_calls` to be false." Note the field cannot simply be pinned to false unconditionally: without tools OpenAI rejects it with "'parallel_tool_calls' is only allowed when 'tools' are specified", so the two constraints have to be honoured together. Reuse the same tool-detection standard the Lite path already uses by adding openAIRequestBodyHasTools, the []byte counterpart of openAIResponsesLiteHasTools, so both sides of the repo agree on what "has tools" means. Co-Authored-By: Claude Opus 5 --- .../service/openai_gateway_request_body.go | 28 +++++++++++++++++-- ...nai_gateway_request_body_reasoning_test.go | 20 +++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index c9a61d7465..8aa4364777 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -366,8 +366,7 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er if !parallel.Exists() { return body, false, nil } - tools := gjson.GetBytes(body, "tools") - if tools.IsArray() && len(tools.Array()) > 0 { + if openAIRequestBodyHasTools(body) { return body, false, nil } normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls") @@ -377,6 +376,31 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er return normalized, true, nil } +// openAIRequestBodyHasTools is the []byte counterpart of openAIResponsesLiteHasTools: +// besides the top-level "tools" array it also recognizes the Responses Lite carrier. +// normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type +// "additional_tools" and drops the top-level "tools" key; the request still carries +// tools at that point. Looking only at the top level therefore misreads such a body as +// "no tools" and deletes the parallel_tool_calls:false that +// ensureOpenAIResponsesLiteParallelToolCalls had just pinned, and OpenAI falls back to +// its default of true and rejects the request with +// 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires +// `parallel_tool_calls` to be false." +func openAIRequestBodyHasTools(body []byte) bool { + if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + for _, item := range gjson.GetBytes(body, "input").Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + } + return false +} + func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) { if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False { return body, false, nil diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index f14a75018b..03397e751f 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -270,3 +270,23 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) } + +// A Responses Lite body that has already been through normalizeOpenAIResponsesLiteTools +// carries its tools in an input item of type "additional_tools" and no longer has a +// top-level "tools" key. It still has tools, so the parallel_tool_calls:false that +// ensureOpenAIResponsesLiteParallelToolCalls pinned must survive this normalization — +// otherwise OpenAI applies its default of true and rejects the request. +func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { + liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) + + // An empty additional_tools item carries no tools, so the field is still dropped. + emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) +} From 9fb260439f7591de1f538d38074c101aa3703fd2 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 24 Aug 2026 22:14:45 +0800 Subject: [PATCH 54/64] fix(grok): use official CLI user agent --- backend/internal/pkg/xai/billing.go | 2 +- backend/internal/pkg/xai/cli_identity_test.go | 6 +-- .../internal/repository/http_upstream_test.go | 49 ++++++++++--------- .../internal/service/grok_observed_models.go | 2 +- .../service/grok_quota_service_test.go | 3 +- .../internal/service/grok_upstream_headers.go | 4 -- .../openai_gateway_chat_completions_raw.go | 2 +- .../internal/service/openai_gateway_grok.go | 15 +++--- .../service/openai_gateway_grok_test.go | 10 ++-- 9 files changed, 45 insertions(+), 48 deletions(-) diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 44e4772d44..39e1a2c8b7 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -19,7 +19,7 @@ const ( // repository and service layers build their own client identity from it, so // one bump here covers OAuth traffic and billing probes together. // Keep in sync with https://x.ai/cli/stable. - CLIClientVersion = "0.2.114" + CLIClientVersion = "0.2.120" // billingCLIUserAgent is the legacy pager/shell UA used by billing probes. // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA). billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go index 521df7483d..2057b9effd 100644 --- a/backend/internal/pkg/xai/cli_identity_test.go +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") ApplyCLIProxyHeaders(req) @@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") ApplyCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("x-grok-client-identifier")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index ac0c064b20..3c7840dc47 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") resp, err := svc.Do(req, "", accountID, 1) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent")) }) } } @@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-Injected")) }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) // Every entry sits above the pinned minimum, so a rejection here can only be // caused by the malformed semver and never by the version being too old. for _, version := range []string{ - "0.2.0115", - "0.2.115-alpha..1", + "0.2.0121", + "0.2.121-alpha..1", "0.3", "1", - "0.2.115+build.1", + "0.2.121+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) } @@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") applyGrokCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) }) } diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go index 480ba457bd..3dba5f0013 100644 --- a/backend/internal/service/grok_observed_models.go +++ b/backend/internal/service/grok_observed_models.go @@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account * } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", grokUpstreamUserAgent) + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) if account.IsGrokOAuth() { applyGrokCLIHeaders(req.Header) if isGrokCLIProxyTarget(req.URL.String()) { diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 9d94d1b989..af27b67f12 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { require.NoError(t, svc.syncGrokObservedModels(context.Background(), account)) require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String()) - require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version")) require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID")) require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email")) diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go index cdfb54bcbf..4b24e4c13b 100644 --- a/backend/internal/service/grok_upstream_headers.go +++ b/backend/internal/service/grok_upstream_headers.go @@ -10,10 +10,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) -// grokUpstreamUserAgent is kept for compatibility with older Grok request -// tests. Current requests use the pinned default UA from this package. -const grokUpstreamUserAgent = "sub2api-grok/1.0" - // Fixed CLI identity aliases — single source of truth is internal/pkg/xai. const ( grokClientVersionHeader = xai.CLIStableVersion diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 923033c4d4..7427c6fc9c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { - customUA = "sub2api-grok/1.0" + customUA = defaultGrokUpstreamUserAgent() } resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 10a2731fbb..21c7342356 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -22,14 +22,13 @@ import ( const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 - // grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers). - grokCLIVersion = xai.CLIClientVersion - grokDefaultResponsesModel = "grok-4.5" - grokRateLimitFallbackCooldown = 2 * time.Minute - grokRateLimitRepeatCooldown = 10 * time.Minute - grokRateLimitSustainedCooldown = 30 * time.Minute - grokRateLimitMaxAdaptiveCooldown = time.Hour - grokRateLimitBackoffQuietPeriod = time.Hour + grokCLIVersion = xai.CLIClientVersion + grokDefaultResponsesModel = "grok-4.5" + grokRateLimitFallbackCooldown = 2 * time.Minute + grokRateLimitRepeatCooldown = 10 * time.Minute + grokRateLimitSustainedCooldown = 30 * time.Minute + grokRateLimitMaxAdaptiveCooldown = time.Hour + grokRateLimitBackoffQuietPeriod = time.Hour ) func (s *OpenAIGatewayService) forwardGrokResponses( diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index e11954a4bc..d1c966ac07 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -828,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing. require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String()) require.Equal(t, "Bearer api-key", req.Header.Get("Authorization")) require.Empty(t, req.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) } func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) { @@ -1132,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) { require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody)) require.Equal(t, http.StatusOK, recorder.Code) require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String()) @@ -1646,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { require.Equal(t, http.MethodGet, upstream.lastReq.Method) require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastBody) require.Equal(t, http.StatusOK, recorder.Code) @@ -1982,7 +1982,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "resp_grok_api_key", result.ResponseID) require.Equal(t, 2, result.Usage.InputTokens) @@ -2259,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) } func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) { From eb594eefc952e53ffc8b3494d9023ecd3df03e8c Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Mon, 24 Aug 2026 22:29:02 +0800 Subject: [PATCH 55/64] fix(payment): refresh balance after fulfillment --- frontend/src/views/user/PaymentResultView.vue | 18 ++++++++ .../user/__tests__/PaymentResultView.spec.ts | 42 +++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index a5f32f0a8d..981377ce97 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -107,6 +107,7 @@ import { readPaymentRecoverySnapshot, } from '@/components/payment/paymentFlow' import { usePaymentStore } from '@/stores/payment' +import { useAuthStore } from '@/stores/auth' import { paymentAPI } from '@/api/payment' import type { PublicOrderVerifyResult } from '@/api/payment' import type { OrderStatus, PaymentOrder } from '@/types/payment' @@ -118,6 +119,7 @@ const { t } = i18n const route = useRoute() const router = useRouter() const paymentStore = usePaymentStore() +const authStore = useAuthStore() type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult @@ -139,6 +141,7 @@ const STATUS_REFRESH_INTERVAL_MS = 2000 const STATUS_REFRESH_MAX_ATTEMPTS = 15 let statusRefreshTimer: ReturnType | null = null +let userBalanceRefreshStarted = false const refreshAttempts = ref(0) /** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */ @@ -197,6 +200,21 @@ function setResolvedOrder(nextOrder: ResolvedOrder | null): void { if (nextOrder && 'currency' in nextOrder && nextOrder.currency) { currency.value = normalizePaymentCurrency(nextOrder.currency) } + refreshUserBalanceForSuccessfulOrder(nextOrder) +} + +function refreshUserBalanceForSuccessfulOrder(nextOrder: ResolvedOrder | null): void { + if (!nextOrder || userBalanceRefreshStarted || normalizeOrderStatus(nextOrder.status) !== 'COMPLETED') { + return + } + if ('order_type' in nextOrder && nextOrder.order_type !== 'balance') { + return + } + + userBalanceRefreshStarted = true + void authStore.refreshUser().catch(() => { + // The order result remains authoritative even if refreshing profile data fails. + }) } function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder { diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 5edae835c0..751e7d83c4 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -10,6 +10,7 @@ const pollOrderStatus = vi.hoisted(() => vi.fn()) const verifyOrder = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn()) const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) +const refreshUser = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router') @@ -36,6 +37,12 @@ vi.mock('@/stores/payment', () => ({ }), })) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + refreshUser, + }), +})) + vi.mock('@/api/payment', () => ({ paymentAPI: { verifyOrder, @@ -91,6 +98,8 @@ describe('PaymentResultView', () => { verifyOrder.mockReset() verifyOrderPublic.mockReset() resolveOrderPublicByResumeToken.mockReset() + refreshUser.mockReset() + refreshUser.mockResolvedValue({}) window.localStorage.clear() }) @@ -171,7 +180,7 @@ describe('PaymentResultView', () => { })) resolveOrderPublicByResumeToken.mockResolvedValue({ data: { - ...orderFactory('PAID'), + ...orderFactory('COMPLETED'), amount: 100, pay_amount: 103, fee_rate: 3, @@ -190,13 +199,14 @@ describe('PaymentResultView', () => { expect(pollOrderStatus).not.toHaveBeenCalled() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative') + expect(refreshUser).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('103.00') expect(wrapper.text()).toContain('100.00') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) - it('refreshes a pending resume-token result until the order becomes paid', async () => { + it('waits for completed fulfillment before refreshing the user balance', async () => { vi.useFakeTimers() routeState.query = { resume_token: 'resume-77', @@ -210,7 +220,7 @@ describe('PaymentResultView', () => { data: orderFactory('PENDING'), }) .mockResolvedValueOnce({ - data: orderFactory('PAID'), + data: orderFactory('COMPLETED'), }) const wrapper = mount(PaymentResultView, { @@ -224,6 +234,7 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1) + expect(refreshUser).not.toHaveBeenCalled() expect(wrapper.text()).toContain('payment.result.processing') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull() @@ -231,11 +242,36 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2) + expect(refreshUser).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) + it('keeps the successful result when refreshing the user balance fails', async () => { + routeState.query = { + resume_token: 'resume-refresh-failure', + } + resolveOrderPublicByResumeToken.mockResolvedValue({ + data: orderFactory('COMPLETED'), + }) + refreshUser.mockRejectedValueOnce(new Error('profile refresh failed')) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(refreshUser).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('payment.result.success') + expect(wrapper.text()).not.toContain('payment.result.failed') + }) + it('falls back to order_id polling when resume-token recovery fails', async () => { routeState.query = { resume_token: 'resume-fail', From e2d9b823f63dc4e8f4014be3fd24a0a73e339867 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:35:57 +0000 Subject: [PATCH 56/64] chore: sync VERSION to 0.1.181 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 2005625e83..7fcea722aa 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.180 +0.1.181 From 329b92ef045fd24b49b33e719e42facc7b26e1b2 Mon Sep 17 00:00:00 2001 From: SipengXie2024 <184713193+SipengXie2024@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:27:24 +0000 Subject: [PATCH 57/64] fix(openai): preserve OAuth image prompts verbatim --- backend/internal/service/openai_images.go | 13 +++++++------ .../internal/service/openai_images_responses.go | 1 + backend/internal/service/openai_images_test.go | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index bc6b6b807f..c661aade36 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -34,12 +34,13 @@ const ( openAIImagesGenerationsURL = "https://api.openai.com/v1/images/generations" openAIImagesEditsURL = "https://api.openai.com/v1/images/edits" - openAIChatGPTStartURL = "https://chatgpt.com/" - openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" - openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download - openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part - openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIChatGPTStartURL = "https://chatgpt.com/" + openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" + openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download + openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part + openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIImagesVerbatimPromptInstructions = "When invoking the image_generation tool, use the user's image prompt verbatim. Do not rewrite, expand, summarize, embellish, translate, normalize punctuation, or add or remove visual details or constraints. Preserve the original language, wording, capitalization, quotes, and punctuation exactly." ) type OpenAIImagesCapability string diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 9e72aaecc4..9374a6f7c7 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -356,6 +356,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel) + req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 6761c55131..f06c213f46 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -2011,6 +2011,21 @@ func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) { require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String()) } +func TestBuildOpenAIImagesResponsesRequest_RequiresVerbatimUserPrompt(t *testing.T) { + prompt := "画一个蓝色马克杯,杯身只写“SkelOT”,保持大小写;白色背景,不要增加其他文字。" + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: prompt, + N: 1, + } + + body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2") + require.NoError(t, err) + require.Equal(t, openAIImagesVerbatimPromptInstructions, gjson.GetBytes(body, "instructions").String()) + require.Equal(t, prompt, gjson.GetBytes(body, "input.0.content.0.text").String()) +} + func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) { body := []byte( "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" + From 4347e5555b674bc5cd0a09fc733ad2afc6976b11 Mon Sep 17 00:00:00 2001 From: HypoxanthineOvO Date: Mon, 24 Aug 2026 23:09:30 +0800 Subject: [PATCH 58/64] fix(composite): route Kimi Code K3 model IDs --- .../internal/handler/composite_platform_test.go | 1 + backend/internal/service/composite_platform.go | 4 +++- .../internal/service/composite_platform_test.go | 4 ++++ .../service/composite_route_resolver_test.go | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index be1706fba3..6e068a4614 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -31,6 +31,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { }{ {model: "grok-4.3", platform: service.PlatformGrok}, {model: "kimi-k2-thinking", platform: service.PlatformKimi}, + {model: "k3", platform: service.PlatformKimi}, {model: "glm-5.2", platform: service.PlatformZhipu}, {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, } diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index e4d836febe..c7736f9582 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -139,7 +139,9 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case normalized == "grok" || strings.HasPrefix(normalized, "grok-"): return PlatformGrok, true - case strings.HasPrefix(normalized, "kimi-"), + case normalized == "k3", + normalized == "k3-256k", + strings.HasPrefix(normalized, "kimi-"), strings.HasPrefix(normalized, "moonshot-"): return PlatformKimi, true case strings.HasPrefix(normalized, "glm-"): diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index e145634889..5373a7fc6f 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -26,9 +26,13 @@ func TestDetectModelPlatform(t *testing.T) { {name: "grok", model: "grok-4", platform: PlatformGrok, ok: true}, {name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true}, {name: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3", model: "K3", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3 256k", model: "k3-256k", platform: PlatformKimi, ok: true}, + {name: "kimi code provider prefix", model: "kimi-code/k3", platform: PlatformKimi, ok: true}, {name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true}, {name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true}, {name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true}, + {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 35028934dd..60e4bd0b82 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -191,6 +191,22 @@ func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *te require.Nil(t, decision.Route) } +func TestCompositeRouteResolverDetectsKimiCodeBareModels(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + + for _, model := range []string{"k3", "k3-256k", "kimi-code/k3"} { + t.Run(model, func(t *testing.T) { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceDetector, decision.Source) + require.Equal(t, PlatformKimi, decision.TargetPlatform) + require.Equal(t, model, decision.UpstreamModel) + }) + } +} + func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ From 53d76ad80020b5846580ce2d44b77769f44bd1c6 Mon Sep 17 00:00:00 2001 From: Jiao Ziang <2720649216@qq.com> Date: Tue, 25 Aug 2026 00:21:29 +0800 Subject: [PATCH 59/64] fix(openai): enforce Responses Lite tool call mode --- .../service/openai_gateway_forward.go | 5 +- .../service/openai_responses_lite_tools.go | 37 +++++- .../openai_responses_lite_tools_test.go | 114 +++++++++++++++++- .../service/openai_ws_forwarder_ingress.go | 4 +- ...penai_ws_forwarder_ingress_session_test.go | 10 +- .../openai_ws_v2_passthrough_adapter.go | 8 +- 6 files changed, 162 insertions(+), 16 deletions(-) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index ef53b6732d..38d747ca33 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -76,8 +76,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = reasoningBody } } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { - liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) + responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) + if responsesLite { + liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) if liteErr != nil { param := "tools" var validationErr *openAIResponsesLiteValidationError diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index f374b59bbf..b13e1443d0 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -101,8 +101,17 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { - parallel := reqBody["parallel_tool_calls"] + parallel, exists := reqBody["parallel_tool_calls"] + if exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if !openAIResponsesLiteHasTools(reqBody) { + if exists { + delete(reqBody, "parallel_tool_calls") + return true, nil + } return changed, nil } if parallel == false { @@ -267,3 +276,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) } return rebuilt, true, nil } + +func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) { + var requestBody map[string]any + if err := json.Unmarshal(body, &requestBody); err != nil { + return body, false, fmt.Errorf("decode responses Lite request body: %w", err) + } + changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false) + if err != nil || !changed { + return body, false, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, false, fmt.Errorf("encode responses Lite request body: %w", err) + } + return rebuilt, true, nil +} + +func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) { + if account == nil || !account.IsOpenAI() { + return body, false, nil + } + if account.IsOpenAIOAuthLike() { + return normalizeOpenAIResponsesLiteToolsPayload(body) + } + return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body) +} diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index 145ddf95b2..e1166ef4e9 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -201,17 +201,25 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi } } -func TestNormalizeOpenAIResponsesLiteTools_DoesNotAddParallelToolCallsWithoutTools(t *testing.T) { - reqBody := map[string]any{ - "reasoning": map[string]any{"context": "all_turns"}, - "parallel_tool_calls": true, +func TestNormalizeOpenAIResponsesLiteTools_RemovesParallelToolCallsWithoutTools(t *testing.T) { + for _, value := range []bool{true, false} { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.NotContains(t, reqBody, "parallel_tool_calls") } + reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} changed, err := normalizeOpenAIResponsesLiteTools(reqBody) - require.NoError(t, err) require.False(t, changed) - require.Equal(t, true, reqBody["parallel_tool_calls"]) + require.NotContains(t, reqBody, "parallel_tool_calls") } func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { @@ -453,3 +461,97 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes }) } } + +func TestOpenAIGatewayServiceForward_RemovesParallelToolCallsForToollessResponsesLite(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists(), string(upstream.lastBody)) + }) + } +} + +func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + }) + } +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5eff93fc2e..5f9aed1f61 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -320,8 +320,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if accountScoped { normalized = accountScopedPayload } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) + if isOpenAIResponsesLiteWebSocketPayload(normalized) { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( coderws.StatusPolicyViolation, diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 097d845037..2e09082d6e 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1361,7 +1361,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}] + }`)) cancelWrite() require.NoError(t, err) @@ -1402,6 +1407,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe } require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游") + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 96e1c859bb..d22e386e11 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -679,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { - liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) + if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account) if liteErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -1011,8 +1011,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } if isResponseCreate { - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) + if isOpenAIResponsesLiteWebSocketPayload(payload) { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } From d6012b0b351b43d5170a63ae1d562c81f7a5d53b Mon Sep 17 00:00:00 2001 From: Jiao Ziang <2720649216@qq.com> Date: Tue, 25 Aug 2026 01:05:28 +0800 Subject: [PATCH 60/64] fix(openai): preserve numeric precision in Lite payloads --- .../service/openai_responses_lite_tools.go | 5 ++-- .../openai_responses_lite_tools_test.go | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index b13e1443d0..752ed00561 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -1,7 +1,6 @@ package service import ( - "encoding/json" "fmt" "reflect" "strings" @@ -263,7 +262,7 @@ func openAIResponsesLiteToolIdentityForError(rawTool any) string { func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) { var requestBody map[string]any - if err := json.Unmarshal(body, &requestBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { return body, false, fmt.Errorf("decode responses Lite request body: %w", err) } changed, err := normalizeOpenAIResponsesLiteTools(requestBody) @@ -279,7 +278,7 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) { var requestBody map[string]any - if err := json.Unmarshal(body, &requestBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { return body, false, fmt.Errorf("decode responses Lite request body: %w", err) } changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false) diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index e1166ef4e9..3edff5c2da 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -344,6 +344,34 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) } +func TestNormalizeOpenAIResponsesLitePayloads_PreserveLargeSequence(t *testing.T) { + body := []byte(`{ + "type":"response.create", + "sequence":900719925474099312345, + "tools":[{"type":"function","name":"lookup"}], + "parallel_tool_calls":true + }`) + tests := []struct { + name string + normalize func([]byte) ([]byte, bool, error) + }{ + {name: "OAuth-like tools normalization", normalize: normalizeOpenAIResponsesLiteToolsPayload}, + {name: "API key parallel normalization", normalize: normalizeOpenAIResponsesLiteParallelToolCallsPayload}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, changed, err := tt.normalize(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "900719925474099312345", gjson.GetBytes(updated, "sequence").Raw) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) + }) + } +} + func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.6-terra", From d5e43ef7d13e76b551cdd19ef38e1f1fb9866a7c Mon Sep 17 00:00:00 2001 From: Jiao Ziang <2720649216@qq.com> Date: Tue, 25 Aug 2026 03:46:46 +0800 Subject: [PATCH 61/64] fix(openai): normalize Lite requests in WS HTTP bridge --- backend/internal/service/openai_ws_http_bridge.go | 9 +++++++++ backend/internal/service/openai_ws_http_bridge_test.go | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 26a08c1bf1..2bb0c200b5 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -349,6 +349,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( LoweredTools: loweredTools, }) } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + liteBody, liteChanged, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) + if liteErr != nil { + return nil, fmt.Errorf("normalize responses Lite payload: %w", liteErr) + } + if liteChanged { + body = liteBody + } + } buildUpstreamRequest := func(requestBody []byte) (*http.Request, error) { upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 59ee0a548e..752fdcca19 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1239,7 +1239,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { Concurrency: 1, Status: StatusActive, } - payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`) + payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"parallel_tool_calls":true}`) type bridgeResult struct { result *OpenAIForwardResult @@ -1325,6 +1325,8 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T) { From 3b7753a8e4a72791001ef558eebbd6e05bee8d9d Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 25 Aug 2026 08:51:58 +0800 Subject: [PATCH 62/64] chore: update sponsors --- README.md | 5 +++++ README_CN.md | 5 +++++ README_JA.md | 5 +++++ assets/partners/logos/apimart.jpg | Bin 0 -> 60980 bytes 4 files changed, 15 insertions(+) create mode 100644 assets/partners/logos/apimart.jpg diff --git a/README.md b/README.md index e5d3221c1c..16ebab0a32 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,11 @@ Please read the following carefully before using this project: DuckIP - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial. + +APIMart +Thanks to APIMart for sponsoring this project! APIMart is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — sign up here to get started. + + ## Overview diff --git a/README_CN.md b/README_CN.md index 10329d4d8f..e879045374 100644 --- a/README_CN.md +++ b/README_CN.md @@ -165,6 +165,11 @@ DuckIP - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。 + +APIMart +感谢 APIMart 赞助了本项目!APIMart 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过此注册链接注册即可开始使用。 + + ## 项目概述 diff --git a/README_JA.md b/README_JA.md index 8727367980..17890e4557 100644 --- a/README_JA.md +++ b/README_JA.md @@ -164,6 +164,11 @@ DuckIP - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。 + +APIMart +APIMart のご支援に感謝します!APIMart は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、こちらの登録リンクからすぐに利用を開始できます。 + + ## 概要 diff --git a/assets/partners/logos/apimart.jpg b/assets/partners/logos/apimart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e670cea236e5e95ebcba111f98da0178d1e490df GIT binary patch literal 60980 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 literal 0 HcmV?d00001 From 095b525360fc597e8af55e0ddd61bca76f474ddf Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 25 Aug 2026 12:14:52 +0800 Subject: [PATCH 63/64] fix(openai): pin Responses Lite parallel tool calls --- .../service/openai_codex_tool_names_test.go | 2 +- .../openai_compact_body_signal_test.go | 2 +- .../openai_gateway_apikey_item_id_test.go | 4 +- .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_passthrough.go | 3 +- .../service/openai_gateway_request_body.go | 22 +-- ...nai_gateway_request_body_reasoning_test.go | 23 +-- .../openai_passthrough_normalization_test.go | 14 +- .../service/openai_responses_lite_tools.go | 24 --- .../openai_responses_lite_tools_test.go | 139 +++++++++++------- .../openai_responses_tool_schema_test.go | 4 +- .../service/openai_ws_forwarder_ingress.go | 5 +- ...penai_ws_forwarder_ingress_session_test.go | 3 +- .../service/openai_ws_http_bridge_test.go | 2 +- .../openai_ws_v2_passthrough_adapter.go | 8 +- ...openai_ws_v2_passthrough_lifecycle_test.go | 36 ++++- 16 files changed, 166 insertions(+), 127 deletions(-) diff --git a/backend/internal/service/openai_codex_tool_names_test.go b/backend/internal/service/openai_codex_tool_names_test.go index d6cb9eafa1..73b7164deb 100644 --- a/backend/internal/service/openai_codex_tool_names_test.go +++ b/backend/internal/service/openai_codex_tool_names_test.go @@ -91,7 +91,7 @@ func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) { require.Equal(t, codexPythonToolAlias, tool["name"]) apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`) - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(apiKeyBody), string(normalized)) diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go index db8b6b404b..16587df0fd 100644 --- a/backend/internal/service/openai_compact_body_signal_test.go +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -96,7 +96,7 @@ func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *test body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) require.NoError(t, err) require.True(t, changed) items := gjson.GetBytes(normalized, "input").Array() diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go index e3a93bebb3..d5be3cec49 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -271,7 +271,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.True(t, changed) @@ -285,7 +285,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.False(t, changedAgain) require.JSONEq(t, string(normalized), string(second)) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 38d747ca33..6487385000 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -152,7 +152,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) } if account.IsOpenAIApiKey() { - if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil { + if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil { return nil, normalizeErr } else if changed { body = normalized diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d5d0c75408..b87ce9df87 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -212,7 +212,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } } if account != nil && account.IsOpenAI() { - normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account) + responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) + normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite) if normalizeErr != nil { return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 8aa4364777..6272875cc5 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -349,7 +349,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { } normalized = next } - if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil { + if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil { return body, false, err } else if removed { normalized = next @@ -361,7 +361,10 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { return normalized, true, nil } -func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) { +func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) { + if responsesLite { + return body, false, nil + } parallel := gjson.GetBytes(body, "parallel_tool_calls") if !parallel.Exists() { return body, false, nil @@ -376,16 +379,7 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er return normalized, true, nil } -// openAIRequestBodyHasTools is the []byte counterpart of openAIResponsesLiteHasTools: -// besides the top-level "tools" array it also recognizes the Responses Lite carrier. -// normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type -// "additional_tools" and drops the top-level "tools" key; the request still carries -// tools at that point. Looking only at the top level therefore misreads such a body as -// "no tools" and deletes the parallel_tool_calls:false that -// ensureOpenAIResponsesLiteParallelToolCalls had just pinned, and OpenAI falls back to -// its default of true and rejects the request with -// 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires -// `parallel_tool_calls` to be false." +// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。 func openAIRequestBodyHasTools(body []byte) bool { if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { return true @@ -948,7 +942,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) return normalized, true, nil } -func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) { +func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { if account == nil || !account.IsOpenAI() { return body, false, nil } @@ -962,7 +956,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac } } if account.IsOpenAIApiKey() { - if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil { + if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil { return body, false, err } else if normalizedParallel { normalized = next diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 03397e751f..543d294880 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -259,34 +259,37 @@ func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedCont func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`) - normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false) require.NoError(t, err) require.False(t, changed) require.Equal(t, string(withTools), string(normalized)) withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`) - normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false) require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) } -// A Responses Lite body that has already been through normalizeOpenAIResponsesLiteTools -// carries its tools in an input item of type "additional_tools" and no longer has a -// top-level "tools" key. It still has tools, so the parallel_tool_calls:false that -// ensureOpenAIResponsesLiteParallelToolCalls pinned must survive this normalization — -// otherwise OpenAI applies its default of true and rejects the request. +// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) - normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false) require.NoError(t, err) require.False(t, changed) require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) - // An empty additional_tools item carries no tools, so the field is still dropped. + // 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。 emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`) - normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false) require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) + + // Lite 请求即使没有工具,也必须保留已经固定的 false。 + toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) } diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index ac5cb87c71..f86ecc6927 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -55,7 +55,7 @@ func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) { body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) - oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}) + oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String()) @@ -63,7 +63,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthField require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field) } - apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(body), string(apiKeyBody)) @@ -81,7 +81,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI if oauth { accountType = AccountTypeOAuth } - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String()) @@ -105,7 +105,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseRepl normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - }) + }, false) require.NoError(t, err) require.True(t, changed) @@ -141,13 +141,13 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) { body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`) for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) } - apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(body), string(apiKeyBody)) @@ -156,7 +156,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccount func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) { body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`) for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index 752ed00561..7f6c7f58e5 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -106,13 +106,6 @@ func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") } } - if !openAIResponsesLiteHasTools(reqBody) { - if exists { - delete(reqBody, "parallel_tool_calls") - return true, nil - } - return changed, nil - } if parallel == false { return changed, nil } @@ -120,23 +113,6 @@ func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed return true, nil } -func openAIResponsesLiteHasTools(reqBody map[string]any) bool { - if tools, ok := reqBody["tools"].([]any); ok && len(tools) > 0 { - return true - } - input, _ := reqBody["input"].([]any) - for _, rawItem := range input { - item, ok := rawItem.(map[string]any) - if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { - continue - } - if tools, ok := item["tools"].([]any); ok && len(tools) > 0 { - return true - } - } - return false -} - func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) { rawReasoning, exists := reqBody["reasoning"] if !exists || rawReasoning == nil { diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index 3edff5c2da..4088882d24 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -201,25 +201,33 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi } } -func TestNormalizeOpenAIResponsesLiteTools_RemovesParallelToolCallsWithoutTools(t *testing.T) { - for _, value := range []bool{true, false} { - reqBody := map[string]any{ - "reasoning": map[string]any{"context": "all_turns"}, - "parallel_tool_calls": value, - } - - changed, err := normalizeOpenAIResponsesLiteTools(reqBody) - - require.NoError(t, err) - require.True(t, changed) - require.NotContains(t, reqBody, "parallel_tool_calls") +func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) { + tests := []struct { + name string + parallel any + include bool + wantChanged bool + }{ + {name: "字段缺失", wantChanged: true}, + {name: "值为 true", parallel: true, include: true, wantChanged: true}, + {name: "值为 false", parallel: false, include: true, wantChanged: false}, } - reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} - changed, err := normalizeOpenAIResponsesLiteTools(reqBody) - require.NoError(t, err) - require.False(t, changed) - require.NotContains(t, reqBody, "parallel_tool_calls") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} + if tt.include { + reqBody["parallel_tool_calls"] = tt.parallel + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.Equal(t, tt.wantChanged, changed) + require.Contains(t, reqBody, "parallel_tool_calls") + require.Equal(t, false, reqBody["parallel_tool_calls"]) + }) + } } func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { @@ -490,49 +498,70 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes } } -func TestOpenAIGatewayServiceForward_RemovesParallelToolCallsForToollessResponsesLite(t *testing.T) { +func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(t *testing.T) { gin.SetMode(gin.TestMode) - for _, passthrough := range []bool{false, true} { - name := "managed" - if passthrough { - name = "passthrough" - } - t.Run(name, func(t *testing.T) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) - c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") - c.Request.Header.Set(responsesLiteHeader, "true") - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader( - "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + - "data: [DONE]\n\n", - )), - }} - svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} - account := &Account{ - ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), - Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, - Extra: map[string]any{"openai_passthrough": passthrough}, + accountCases := []struct { + name string + accountType string + credentials map[string]any + }{ + {name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}}, + {name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}}, + } + parallelCases := []struct { + name string + field string + }{ + {name: "字段缺失"}, + {name: "值为 true", field: `,"parallel_tool_calls":true`}, + {name: "值为 false", field: `,"parallel_tool_calls":false`}, + } + + for _, accountCase := range accountCases { + for _, passthrough := range []bool{false, true} { + mode := "managed" + if passthrough { + mode = "passthrough" } - body := []byte(`{ - "model":"gpt-5.6-terra","stream":true,"instructions":"test", - "reasoning":{"effort":"high","context":"current_turn"}, - "parallel_tool_calls":true, - "input":[{"type":"message","role":"user","content":"hello"}] - }`) + for _, parallelCase := range parallelCases { + name := accountCase.name + "/" + mode + "/" + parallelCase.name + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: accountCase.credentials, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, + "input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + ` + }`) - result, err := svc.Forward(context.Background(), c, account, body) + result, err := svc.Forward(context.Background(), c, account, body) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) - require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists(), string(upstream.lastBody)) - }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody)) + }) + } + } } } diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index faaf9062de..e899e17098 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -497,7 +497,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) { normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) @@ -508,7 +508,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) { normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformGrok, Type: AccountTypeAPIKey, - }) + }, false) require.NoError(t, err) require.False(t, changed) require.Equal(t, string(body), string(normalized)) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 5f9aed1f61..8dbb5b1abb 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -262,7 +262,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = capped } } - if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account); compatibilityErr != nil { + responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) } else if compatibilityChanged { normalized = compatibilityBody @@ -320,7 +321,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if accountScoped { normalized = accountScopedPayload } - if isOpenAIResponsesLiteWebSocketPayload(normalized) { + if responsesLite { litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 2e09082d6e..b05a2efd10 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1364,8 +1364,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ "type":"response.create","model":"gpt-5.1","stream":false, "parallel_tool_calls":true, - "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, - "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}] + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} }`)) cancelWrite() require.NoError(t, err) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 752fdcca19..13752a55ef 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1239,7 +1239,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { Concurrency: 1, Status: StatusActive, } - payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"parallel_tool_calls":true}`) + payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","parallel_tool_calls":true}`) type bridgeResult struct { result *OpenAIForwardResult diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index d22e386e11..60e7b41416 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -735,7 +735,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil { + firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized @@ -984,8 +985,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }() } + responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized @@ -1011,7 +1013,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } if isResponseCreate { - if isOpenAIResponsesLiteWebSocketPayload(payload) { + if responsesLite { litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index de3d5fdf27..ab7d704b04 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -190,13 +190,18 @@ func startPassthroughLifecycleServer( } func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn { + t.Helper() + return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`) +} + +func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn { t.Helper() dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) cancelDial() require.NoError(t, err) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload)) cancelWrite() require.NoError(t, err) return clientConn @@ -221,6 +226,35 @@ func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughCo } } +func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) + defer server.Close() + clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} + }`) + defer func() { _ = clientConn.CloseNow() }() + + upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second) + require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody)) + + event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("Lite 首帧测试等待 passthrough 退出超时") + } +} + func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) { clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)} clientFrameConn.markTurnCompleted() From aa2c4e8d136b13553ac7bae3d76c25715333a554 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:47:52 +0000 Subject: [PATCH 64/64] chore: sync VERSION to 0.1.182 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 7fcea722aa..ce08f35612 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.181 +0.1.182