diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml index b71422a78a..4e05aae66b 100644 --- a/.github/audit-exceptions.yml +++ b/.github/audit-exceptions.yml @@ -28,3 +28,10 @@ exceptions: mitigation: "No user-controlled template strings; plan to migrate to native JS alternatives" expires_on: "2026-07-02" owner: "security@your-domain" + - package: axios + advisory: "GHSA-3p68-rc4w-qgx5" + severity: critical + reason: "NO_PROXY bypass not exploitable; all API calls go to known endpoints via server-side proxy" + mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix" + expires_on: "2026-07-10" + owner: "security@your-domain" diff --git a/README.md b/README.md index b64a7647c5..c2715eae0c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,16 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot Thanks to YLS Code for sponsoring this project! YLS Code is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus! + +AICodeMirror +Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! + + + +AIGoCode +Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! + + ## Ecosystem diff --git a/README_CN.md b/README_CN.md index 5b6be04ccd..0ace1f77a6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -76,10 +76,20 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 -silkapi +ylscode 感谢 伊莉思Code 赞助了本项目! 伊莉思Code 致力于构建安全的企业级Coding Agent生产力服务,提供稳定快速的 Codex / Claude / Gemini 订阅服务与即用即付API多种方案灵活选择,限时注册赠送 3 天 Codex 试用福利! + +AICodeMirror +感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过此链接注册,首次充值立享 8 折优惠,企业客户最高可享 75 折! + + + +AIGoCode +感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过此链接注册,首次充值可额外获得 10% 赠送额度! + + ## 生态项目 diff --git a/README_JA.md b/README_JA.md index 4e7ae76532..d74ca9cebf 100644 --- a/README_JA.md +++ b/README_JA.md @@ -80,6 +80,16 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを YLS Code のご支援に感謝します!YLS Code は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中! + +AICodeMirror +AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:こちらのリンクから登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ! + + + +AIGoCode +AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:こちらのリンクから登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント! + + ## エコシステム diff --git a/assets/partners/logos/AICodeMirror.jpg b/assets/partners/logos/AICodeMirror.jpg new file mode 100644 index 0000000000..1c98b2238a Binary files /dev/null and b/assets/partners/logos/AICodeMirror.jpg differ diff --git a/assets/partners/logos/aigocode.png b/assets/partners/logos/aigocode.png new file mode 100644 index 0000000000..6dd5965ac5 Binary files /dev/null and b/assets/partners/logos/aigocode.png differ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index e05e293553..e25dfa7197 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.56 +0.1.112.1 \ No newline at end of file diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 64709b5b1c..3127a9c55f 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -43,6 +43,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // Server layer ProviderSet server.ProviderSet, + // Payment providers + payment.ProvideRegistry, + payment.ProvideEncryptionKey, + payment.ProvideDefaultLoadBalancer, + service.ProvidePaymentConfigService, + service.ProvidePaymentOrderExpiryService, + // Privacy client factory for OpenAI training opt-out providePrivacyClientFactory, diff --git a/backend/go.mod b/backend/go.mod index 7f11614a5d..66b6cc25b5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -103,6 +103,7 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -174,6 +175,7 @@ require ( golang.org/x/mod v0.32.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0c407c395c..9312af63e5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,6 +162,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 4ec038e068..a266d6a01c 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -124,6 +124,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact "top_p", "frequency_penalty", "presence_penalty", + // prompt_cache_retention is a newer Responses API parameter (cache TTL). + // The ChatGPT internal Codex endpoint rejects it with + // "Unsupported parameter: prompt_cache_retention". Defense-in-depth + // for any OAuth path that reaches this transform — the Cursor + // Responses-shape short-circuit in ForwardAsChatCompletions strips + // it earlier too, but we keep this line so other OAuth callers are + // equally protected. + "prompt_cache_retention", } { if _, ok := reqBody[key]; ok { delete(reqBody, key) diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 889ac61598..993ade0747 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -481,6 +481,26 @@ func TestExtractSystemMessagesFromInput(t *testing.T) { }) } +// TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression +// test: some clients (e.g. Cursor cloud via the Responses-shape compat path) +// send prompt_cache_retention, but the ChatGPT internal Codex endpoint +// rejects it with "Unsupported parameter: prompt_cache_retention". +func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.1", + "prompt_cache_retention": "24h", + "input": []any{ + map[string]any{"role": "user", "content": "hi"}, + }, + } + + applyCodexOAuthTransform(reqBody, false, false) + + _, stillThere := reqBody["prompt_cache_retention"] + require.False(t, stillThere, + "prompt_cache_retention must be stripped before forwarding to Codex upstream") +} + func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.1", diff --git a/backend/internal/service/openai_cursor_warmup_pipeline_test.go b/backend/internal/service/openai_cursor_warmup_pipeline_test.go new file mode 100644 index 0000000000..19bb13d607 --- /dev/null +++ b/backend/internal/service/openai_cursor_warmup_pipeline_test.go @@ -0,0 +1,199 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// TestCursorMixedShapeDetection covers the core invariant of the Cursor +// compatibility fix in ForwardAsChatCompletions: when a client POSTs a +// Responses-shaped body (has `input`, no `messages`) to /v1/chat/completions, +// the request must be forwarded as-is with only the `model` field rewritten. +// The raw `input` array (including Cursor's 80KB system prompt) must not be +// discarded or reshaped. +// +// Context: +// +// Before the fix, the handler unmarshaled the body into ChatCompletionsRequest, +// which has no Input field, silently dropping Cursor's input. The subsequent +// conversion produced `input: null`, which Codex upstreams reject with +// "Invalid type for 'input': expected a string, but got an object". +func TestCursorMixedShapeDetection(t *testing.T) { + // Representative Cursor cloud body — shape is what matters, content is + // abridged. Notice: `input` is a Responses-API array, there is no + // `messages` field at all, and `user`/`stream` are at the top level. + cursorBody := []byte(`{ + "user": "85df22e7463ab6c2", + "model": "gpt-5.4", + "stream": true, + "input": [ + {"role":"system","content":"You are GPT-5.4 running as a coding agent."}, + {"role":"user","content":"hello"} + ], + "service_tier": "auto", + "reasoning": {"effort": "high"} + }`) + + // --- Step 1: Shape detection (mirrors ForwardAsChatCompletions) --- + hasMessages := gjson.GetBytes(cursorBody, "messages").Exists() + hasInput := gjson.GetBytes(cursorBody, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + require.True(t, isResponsesShape, + "Cursor body must be detected as Responses-shape (has input, no messages)") + + // --- Step 2: Model rewrite (mirrors the sjson.SetBytes branch) --- + const upstreamModel = "gpt-5.1-codex" + rewritten, err := sjson.SetBytes(cursorBody, "model", upstreamModel) + require.NoError(t, err) + + // --- Step 3: Invariants of the rewritten body --- + + // 3a. model must be rewritten to the upstream target. + assert.Equal(t, upstreamModel, gjson.GetBytes(rewritten, "model").String()) + + // 3b. input array must be preserved verbatim — no reshaping, no nulling. + inputResult := gjson.GetBytes(rewritten, "input") + require.True(t, inputResult.Exists(), "input field must still exist after rewrite") + require.True(t, inputResult.IsArray(), "input must still be an array (not null, not object)") + + items := inputResult.Array() + require.Len(t, items, 2, "both input items must be preserved") + assert.Equal(t, "system", items[0].Get("role").String()) + assert.Equal(t, "You are GPT-5.4 running as a coding agent.", + items[0].Get("content").String()) + assert.Equal(t, "user", items[1].Get("role").String()) + assert.Equal(t, "hello", items[1].Get("content").String()) + + // 3c. ALL other top-level fields must survive intact. + assert.Equal(t, "85df22e7463ab6c2", gjson.GetBytes(rewritten, "user").String()) + assert.Equal(t, true, gjson.GetBytes(rewritten, "stream").Bool()) + assert.Equal(t, "auto", gjson.GetBytes(rewritten, "service_tier").String()) + assert.Equal(t, "high", gjson.GetBytes(rewritten, "reasoning.effort").String()) + + // 3d. Final upstream body must NOT contain the old "input":null pattern. + assert.NotContains(t, string(rewritten), `"input":null`, + "rewritten body must not collapse input to null") +} + +// TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected guards that +// the shape detection does NOT misfire on a standard Chat Completions request +// (one that has a `messages` array). Such requests must fall through to the +// existing ChatCompletionsToResponses conversion path. +func TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected(t *testing.T) { + body := []byte(`{ + "model": "gpt-4o", + "messages": [{"role":"user","content":"hi"}], + "stream": true + }`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "standard Chat Completions body must NOT be detected as Responses-shape") +} + +// TestCursorMixedShapeDetection_BothFieldsPrefersMessages guards the +// ambiguous case where a client sends both `messages` and `input`. We fall +// through to the normal conversion path (messages wins), since mixing the +// two is almost certainly a client bug and messages is the documented +// Chat Completions contract. +func TestCursorMixedShapeDetection_BothFieldsPrefersMessages(t *testing.T) { + body := []byte(`{ + "model": "gpt-4o", + "messages": [{"role":"user","content":"hi"}], + "input": [{"role":"user","content":"other"}] + }`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "when both messages and input are present, must not take the Cursor shortcut") +} + +// TestCursorMixedShapeDetection_EmptyBody ensures a body with neither +// messages nor input is NOT taken as Cursor-shape (would hit the normal +// conversion and fail on its own with a clearer error). +func TestCursorMixedShapeDetection_EmptyBody(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","stream":true}`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "body with neither messages nor input must not be taken as Cursor shape") +} + +// TestCursorMixedShape_JSONRoundtrip ensures the rewritten body is still +// valid JSON and parseable back into a map without surprises — catches +// any encoding drift from sjson. +func TestCursorMixedShape_JSONRoundtrip(t *testing.T) { + cursorBody := []byte(`{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"hi"}]}`) + + rewritten, err := sjson.SetBytes(cursorBody, "model", "gpt-5.1-codex") + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(rewritten, &parsed)) + + assert.Equal(t, "gpt-5.1-codex", parsed["model"]) + assert.Equal(t, true, parsed["stream"]) + + inputArr, ok := parsed["input"].([]any) + require.True(t, ok, "input must decode to a Go []any after round-trip") + require.Len(t, inputArr, 1) +} + +// TestCursorMixedShape_StripsUnsupportedFields mirrors the strip loop in +// ForwardAsChatCompletions (isResponsesShape branch). Cursor cloud sends +// prompt_cache_retention, safety_identifier, metadata and stream_options +// as top-level Responses API parameters, which Codex upstreams reject with +// "Unsupported parameter: ...". The fix must remove them from the raw body +// before it is forwarded, for BOTH OAuth and API Key account types. +func TestCursorMixedShape_StripsUnsupportedFields(t *testing.T) { + cursorBody := []byte(`{ + "model": "gpt-5.4", + "stream": true, + "prompt_cache_retention": "24h", + "safety_identifier": "cursor-user-xyz", + "metadata": {"trace_id":"abc","caller":"cursor"}, + "stream_options": {"include_usage": true}, + "input": [{"role":"user","content":"hi"}] + }`) + + // Sanity: the test fixture contains every field the production code strips. + for _, field := range cursorResponsesUnsupportedFields { + require.True(t, gjson.GetBytes(cursorBody, field).Exists(), + "test fixture must contain %s", field) + } + + // Run the exact same loop as the production code. + result := cursorBody + for _, field := range cursorResponsesUnsupportedFields { + if stripped, err := sjson.DeleteBytes(result, field); err == nil { + result = stripped + } + } + + // All unsupported fields must be gone. + for _, field := range cursorResponsesUnsupportedFields { + assert.False(t, gjson.GetBytes(result, field).Exists(), + "%s must be stripped", field) + } + + // Everything else must survive intact. + assert.Equal(t, "gpt-5.4", gjson.GetBytes(result, "model").String()) + assert.Equal(t, true, gjson.GetBytes(result, "stream").Bool()) + assert.True(t, gjson.GetBytes(result, "input").IsArray()) + assert.Equal(t, "user", gjson.GetBytes(result, "input.0.role").String()) +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 25451b2b6b..eb5ce29850 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -16,9 +16,27 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "go.uber.org/zap" ) +// cursorResponsesUnsupportedFields are top-level Responses API parameters that +// Codex upstreams reject with "Unsupported parameter: ...". They must be +// stripped when forwarding a raw client body through the Responses-shape +// short-circuit in ForwardAsChatCompletions (see isResponsesShape branch). +// The normal Chat Completions → Responses conversion path is unaffected +// because ChatCompletionsRequest has no fields for these parameters — unknown +// fields are dropped naturally by json.Unmarshal. Kept semantically in sync +// with the list in openai_gateway_service.go:2034 used by the /v1/responses +// passthrough path. +var cursorResponsesUnsupportedFields = []string{ + "prompt_cache_retention", + "safety_identifier", + "metadata", + "stream_options", +} + // ForwardAsChatCompletions accepts a Chat Completions request body, converts it // to OpenAI Responses API format, forwards to the OpenAI upstream, and converts // the response back to Chat Completions format. All account types (OAuth and API @@ -55,13 +73,62 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( compatPromptCacheInjected = promptCacheKey != "" } - // 3. Convert to Responses and forward - // ChatCompletionsToResponses always sets Stream=true (upstream always streams). - responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) - if err != nil { - return nil, fmt.Errorf("convert chat completions to responses: %w", err) + // 3. Build the upstream (Responses API) body. + // + // Cursor compatibility: some clients (notably Cursor cloud) send Responses + // API shaped bodies — `input: [...]` with no `messages` field — to the + // /v1/chat/completions URL. Running those through ChatCompletionsToResponses + // would silently drop Cursor's `input` array (the struct has no Input field) + // and produce `input: null`, which Codex upstreams reject with + // "Invalid type for 'input': expected a string, but got an object". + // + // Detect that shape and forward the raw body as-is, only rewriting `model` + // to the resolved upstream model. The downstream codex OAuth transform will + // still normalize store/stream/instructions/etc. + isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + + var ( + responsesReq *apicompat.ResponsesRequest + responsesBody []byte + err error + ) + if isResponsesShape { + responsesBody, err = sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, fmt.Errorf("rewrite model in responses-shape body: %w", err) + } + // Strip Responses API parameters that no Codex upstream accepts. + // Because this branch forwards the raw body (the normal path rebuilds + // it from ChatCompletionsRequest and drops unknown fields naturally), + // we must filter these fields explicitly here — otherwise the upstream + // rejects the request with "Unsupported parameter: ...". + for _, field := range cursorResponsesUnsupportedFields { + if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil { + responsesBody = stripped + } + } + // Minimal stub populated from the raw body so downstream billing + // propagation (ServiceTier, ReasoningEffort) keeps working. + responsesReq = &apicompat.ResponsesRequest{ + Model: upstreamModel, + ServiceTier: gjson.GetBytes(responsesBody, "service_tier").String(), + } + if effort := gjson.GetBytes(responsesBody, "reasoning.effort").String(); effort != "" { + responsesReq.Reasoning = &apicompat.ResponsesReasoning{Effort: effort} + } + } else { + // Normal path: convert Chat Completions → Responses. + // ChatCompletionsToResponses always sets Stream=true (upstream always streams). + responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) + if err != nil { + return nil, fmt.Errorf("convert chat completions to responses: %w", err) + } + responsesReq.Model = upstreamModel + responsesBody, err = json.Marshal(responsesReq) + if err != nil { + return nil, fmt.Errorf("marshal responses request: %w", err) + } } - responsesReq.Model = upstreamModel logFields := []zap.Field{ zap.Int64("account_id", account.ID), @@ -69,6 +136,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( zap.String("billing_model", billingModel), zap.String("upstream_model", upstreamModel), zap.Bool("stream", clientStream), + zap.Bool("responses_shape", isResponsesShape), } if compatPromptCacheInjected { logFields = append(logFields, @@ -78,12 +146,6 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } logger.L().Debug("openai chat_completions: model mapping applied", logFields...) - // 4. Marshal Responses request body, then apply OAuth codex transform - responsesBody, err := json.Marshal(responsesReq) - if err != nil { - return nil, fmt.Errorf("marshal responses request: %w", err) - } - { var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 7a4862d335..a72b9bbf4b 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -270,6 +270,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( var finalResponse *apicompat.ResponsesResponse var usage OpenAIUsage + acc := apicompat.NewBufferedResponseAccumulator() for scanner.Scan() { line := scanner.Text() @@ -288,8 +289,12 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( continue } + // Accumulate delta content for fallback when terminal output is empty. + acc.ProcessEvent(&event) + // Terminal events carry the complete ResponsesResponse with output + usage. - if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") && + if (event.Type == "response.completed" || event.Type == "response.done" || + event.Type == "response.incomplete" || event.Type == "response.failed") && event.Response != nil { finalResponse = event.Response if event.Response.Usage != nil { @@ -318,6 +323,10 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( return nil, fmt.Errorf("upstream stream ended without terminal event") } + // When the terminal event has an empty output array, reconstruct from + // accumulated delta events so the client receives the full content. + acc.SupplementResponseOutput(finalResponse) + anthropicResp := apicompat.ResponsesToAnthropic(finalResponse, originalModel) if s.responseHeaderFilter != nil { diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 6753071d3b..14dc1b9a9f 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -57,6 +57,7 @@ func validatePlanPatch(req UpdatePlanRequest) error { return nil } + // --- Plan CRUD --- // PlanGroupInfo holds the group details needed for subscription plan display. @@ -113,11 +114,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe } func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx) + return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx) } func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx) + return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx) } func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4f5b57cc97..4d8009b7bd 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -142,11 +142,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc switch statusCode { case 400: - // 只有当错误信息包含 "organization has been disabled" 时才禁用 + // "organization has been disabled" → 永久禁用 if strings.Contains(strings.ToLower(upstreamMsg), "organization has been disabled") { msg := "Organization disabled (400): " + upstreamMsg s.handleAuthError(ctx, account, msg) shouldDisable = true + } else if account.Platform == PlatformAnthropic && strings.Contains(strings.ToLower(upstreamMsg), "credit balance") { + // Anthropic API key 余额不足(语义等同 402),停止调度 + msg := "Credit balance exhausted (400): " + upstreamMsg + s.handleAuthError(ctx, account, msg) + shouldDisable = true } // 其他 400 错误(如参数问题)不处理,不禁用账号 case 401: diff --git a/backend/migrations/092_payment_orders.sql b/backend/migrations/092_payment_orders.sql new file mode 100644 index 0000000000..036e4dedc0 --- /dev/null +++ b/backend/migrations/092_payment_orders.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS payment_orders ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + user_email VARCHAR(255) NOT NULL DEFAULT '', + user_name VARCHAR(100) NOT NULL DEFAULT '', + user_notes TEXT, + amount DECIMAL(20,2) NOT NULL, + pay_amount DECIMAL(20,2) NOT NULL, + fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0, + recharge_code VARCHAR(64) NOT NULL DEFAULT '', + payment_type VARCHAR(30) NOT NULL DEFAULT '', + payment_trade_no VARCHAR(128) NOT NULL DEFAULT '', + pay_url TEXT, + qr_code TEXT, + qr_code_img TEXT, + order_type VARCHAR(20) NOT NULL DEFAULT 'balance', + plan_id BIGINT, + subscription_group_id BIGINT, + subscription_days INT, + provider_instance_id VARCHAR(64), + status VARCHAR(30) NOT NULL DEFAULT 'PENDING', + refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0, + refund_reason TEXT, + refund_at TIMESTAMPTZ, + force_refund BOOLEAN NOT NULL DEFAULT FALSE, + refund_requested_at TIMESTAMPTZ, + refund_request_reason TEXT, + refund_requested_by VARCHAR(20), + expires_at TIMESTAMPTZ NOT NULL, + paid_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failed_reason TEXT, + client_ip VARCHAR(50) NOT NULL DEFAULT '', + src_host VARCHAR(255) NOT NULL DEFAULT '', + src_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- Indexes +CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id); +CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status); +CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at); +CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at); +CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at); +CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at); +CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type); diff --git a/backend/migrations/093_payment_audit_logs.sql b/backend/migrations/093_payment_audit_logs.sql new file mode 100644 index 0000000000..d05b15ef83 --- /dev/null +++ b/backend/migrations/093_payment_audit_logs.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS payment_audit_logs ( + id BIGSERIAL PRIMARY KEY, + order_id VARCHAR(64) NOT NULL, + action VARCHAR(50) NOT NULL, + detail TEXT NOT NULL DEFAULT '', + operator VARCHAR(100) NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id); diff --git a/backend/migrations/094_removed_payment_channels.sql b/backend/migrations/094_removed_payment_channels.sql new file mode 100644 index 0000000000..cb20234795 --- /dev/null +++ b/backend/migrations/094_removed_payment_channels.sql @@ -0,0 +1,4 @@ +-- Migration 092: payment_channels table was removed before release. +-- This file is a no-op placeholder to maintain migration numbering continuity. +-- The payment system now uses the existing channels table (migration 081). +SELECT 1; diff --git a/backend/migrations/095_subscription_plans.sql b/backend/migrations/095_subscription_plans.sql new file mode 100644 index 0000000000..541d8f0c89 --- /dev/null +++ b/backend/migrations/095_subscription_plans.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS subscription_plans ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + price DECIMAL(20,2) NOT NULL, + original_price DECIMAL(20,2), + validity_days INT NOT NULL DEFAULT 30, + validity_unit VARCHAR(10) NOT NULL DEFAULT 'day', + features TEXT NOT NULL DEFAULT '', + product_name VARCHAR(100) NOT NULL DEFAULT '', + for_sale BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id); +CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale); diff --git a/backend/migrations/096_payment_provider_instances.sql b/backend/migrations/096_payment_provider_instances.sql new file mode 100644 index 0000000000..bedd75df60 --- /dev/null +++ b/backend/migrations/096_payment_provider_instances.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS payment_provider_instances ( + id BIGSERIAL PRIMARY KEY, + provider_key VARCHAR(30) NOT NULL, + name VARCHAR(100) NOT NULL DEFAULT '', + config TEXT NOT NULL, + supported_types VARCHAR(200) NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INT NOT NULL DEFAULT 0, + limits TEXT NOT NULL DEFAULT '', + refund_enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key); +CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled); diff --git a/backend/migrations/097_fix_settings_updated_at_default.sql b/backend/migrations/097_fix_settings_updated_at_default.sql new file mode 100644 index 0000000000..e1d6f9b935 --- /dev/null +++ b/backend/migrations/097_fix_settings_updated_at_default.sql @@ -0,0 +1,27 @@ +-- 097_fix_settings_updated_at_default.sql +-- +-- 修复 settings.updated_at 列在历史实例上可能缺失 SQL DEFAULT 的问题。 +-- +-- 背景: +-- 早期版本曾依赖 ent 自动迁移建表(ent 的 Default(time.Now) 仅是 Go 层默认值, +-- 不会在 SQL 层落地为 DEFAULT),随后引入的 005_schema_parity.sql 使用了 +-- CREATE TABLE IF NOT EXISTS,对已存在的 settings 表不会重建,导致这部分实例 +-- 的 updated_at 列虽然是 NOT NULL,但缺少 SQL DEFAULT。 +-- +-- 后续 098_migrate_purchase_subscription_to_custom_menu.sql 是项目中唯一使用 +-- 原生 SQL INSERT INTO settings 的迁移(其余 settings 写入都走 ent / Go 层), +-- 因此该 schema 缺陷直到 098 才会触发: +-- "null value in column \"updated_at\" of relation \"settings\" violates not-null constraint" +-- +-- 幂等性: +-- - ALTER COLUMN ... SET DEFAULT NOW() 在已经具备相同默认值的实例上是无操作, +-- 不会报错(PostgreSQL 允许重复设置相同的默认值)。 +-- - UPDATE 子句的 WHERE updated_at IS NULL 在健康实例上匹配 0 行,不影响数据。 +-- +-- 这样可以同时兼容: +-- 1. 从未运行过旧版迁移的全新部署(005 已经把列建对,本迁移变成 no-op)。 +-- 2. 历史损坏实例(本迁移修复缺失的默认值,使后续 098 能够正常 INSERT)。 + +ALTER TABLE settings ALTER COLUMN updated_at SET DEFAULT NOW(); + +UPDATE settings SET updated_at = NOW() WHERE updated_at IS NULL; diff --git a/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql b/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql new file mode 100644 index 0000000000..1864459ecc --- /dev/null +++ b/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql @@ -0,0 +1,70 @@ +-- 096_migrate_purchase_subscription_to_custom_menu.sql +-- +-- Migrates the legacy purchase_subscription_url setting into custom_menu_items. +-- After migration, purchase_subscription_enabled is set to "false" and +-- purchase_subscription_url is cleared. +-- +-- Idempotent: skips if custom_menu_items already contains +-- "migrated_purchase_subscription". + +DO $$ +DECLARE + v_enabled text; + v_url text; + v_raw text; + v_items jsonb; + v_new_item jsonb; +BEGIN + -- Read legacy settings + SELECT value INTO v_enabled + FROM settings WHERE key = 'purchase_subscription_enabled'; + SELECT value INTO v_url + FROM settings WHERE key = 'purchase_subscription_url'; + + -- Skip if not enabled or URL is empty + IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN + RETURN; + END IF; + + -- Read current custom_menu_items + SELECT value INTO v_raw + FROM settings WHERE key = 'custom_menu_items'; + + IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN + v_items := '[]'::jsonb; + ELSE + v_items := v_raw::jsonb; + END IF; + + -- Skip if already migrated (item with id "migrated_purchase_subscription" exists) + IF EXISTS ( + SELECT 1 FROM jsonb_array_elements(v_items) elem + WHERE elem ->> 'id' = 'migrated_purchase_subscription' + ) THEN + RETURN; + END IF; + + -- Build the new menu item + v_new_item := jsonb_build_object( + 'id', 'migrated_purchase_subscription', + 'label', 'Purchase', + 'icon_svg', '', + 'url', TRIM(v_url), + 'visibility', 'user', + 'sort_order', 100 + ); + + -- Append to array + v_items := v_items || jsonb_build_array(v_new_item); + + -- Upsert custom_menu_items + INSERT INTO settings (key, value) + VALUES ('custom_menu_items', v_items::text) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + + -- Clear legacy settings + UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled'; + UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url'; + + RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url; +END $$; diff --git a/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql new file mode 100644 index 0000000000..5361ad8166 --- /dev/null +++ b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql @@ -0,0 +1,51 @@ +-- 097_fix_migrated_purchase_menu_label_icon.sql +-- +-- Fixes the custom menu item created by migration 096: updates the label +-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg +-- to a credit-card SVG matching the sidebar CreditCardIcon. +-- +-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'. + +DO $$ +DECLARE + v_raw text; + v_items jsonb; + v_idx int; + v_icon text; + v_elem jsonb; + v_i int := 0; +BEGIN + SELECT value INTO v_raw + FROM settings WHERE key = 'custom_menu_items'; + + IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN + RETURN; + END IF; + + v_items := v_raw::jsonb; + + -- Find the index of the migrated item by iterating the array + v_idx := NULL; + FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP + IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN + v_idx := v_i; + EXIT; + END IF; + v_i := v_i + 1; + END LOOP; + + IF v_idx IS NULL THEN + RETURN; -- item not found, nothing to fix + END IF; + + -- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar) + v_icon := ''; + + -- Update label and icon_svg + v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb); + v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon)); + + UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items'; + + RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG'; +END $$; diff --git a/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql b/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql new file mode 100644 index 0000000000..8128ed09ff --- /dev/null +++ b/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql @@ -0,0 +1,17 @@ +-- 098_remove_easypay_from_enabled_payment_types.sql +-- +-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting. +-- "easypay" is a provider key, not a payment type. Valid payment types +-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe. +-- +-- Idempotent: safe to run multiple times. + +UPDATE settings + SET value = array_to_string( + array_remove( + string_to_array(value, ','), + 'easypay' + ), ',' + ) + WHERE key = 'ENABLED_PAYMENT_TYPES' + AND value LIKE '%easypay%'; diff --git a/backend/migrations/101_add_payment_mode.sql b/backend/migrations/101_add_payment_mode.sql new file mode 100644 index 0000000000..eeb6ba7b2f --- /dev/null +++ b/backend/migrations/101_add_payment_mode.sql @@ -0,0 +1,16 @@ +-- Add payment_mode field to payment_provider_instances +-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A) +ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT ''; + +-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode +-- Remove 'easypay' from supported_types and set payment_mode = 'redirect' +UPDATE payment_provider_instances +SET payment_mode = 'redirect', + supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE( + supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', '')) +WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%'; + +-- EasyPay instances without 'easypay' in supported_types → api mode +UPDATE payment_provider_instances +SET payment_mode = 'api' +WHERE provider_key = 'easypay' AND payment_mode = ''; diff --git a/backend/migrations/102_add_out_trade_no_to_payment_orders.sql b/backend/migrations/102_add_out_trade_no_to_payment_orders.sql new file mode 100644 index 0000000000..896c3c954d --- /dev/null +++ b/backend/migrations/102_add_out_trade_no_to_payment_orders.sql @@ -0,0 +1,6 @@ +-- 100_add_out_trade_no_to_payment_orders.sql +-- Adds out_trade_no column for external order ID used with payment providers. +-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID. + +ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no); diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 800c1a5159..588832252c 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -1,5 +1,5 @@ @@ -144,28 +145,26 @@ @@ -505,7 +504,6 @@ const ChevronDoubleLeftIcon = { ) } - const OrderIcon = { render: () => h( @@ -795,14 +793,120 @@ onMounted(() => {