Skip to content

fix: version HFC security release and credits contact flow#1

Open
MarkDonish wants to merge 161 commits into
mainfrom
codex/credits-security-sync-20260715
Open

fix: version HFC security release and credits contact flow#1
MarkDonish wants to merge 161 commits into
mainfrom
codex/credits-security-sync-20260715

Conversation

@MarkDonish

Copy link
Copy Markdown
Owner

Scope

  • publish the exact HFC production security snapshot as a traceable Git revision
  • remove the remaining public WeChat identifier from purchase and renewal UI
  • align group-access errors with 联系管理员或客服
  • preserve the credits-only sale and wallet security controls already running in production

Fresh verification

  • targeted frontend contact-copy tests: 12/12
  • frontend typecheck and production build
  • focused backend wallet/group-switch tests
  • staged diff secret checks; token-like fixtures are confined to security tests

This PR is intentionally large because the active production security image was previously built from an uncommitted snapshot. The branch makes that deployed source reviewable and provides an OCI revision for the replacement image.

yingcheng1026 and others added 30 commits April 30, 2026 15:48
When multiple OpenAI accounts share similar scores (which is the common
case once errors/load are stable), buildOpenAIWeightedSelectionOrder used
to fall back to near-uniform random and ignored the configured
load_factor entirely (only Concurrency was used as a slot cap).

This caused operators with mixed Pro 20x + Plus pools to see Plus
accounts taking the same share of traffic as Pro 20x, defeating the
whole point of capacity-based pool sizing. See upstream issue Wei-Shaw#979.

The fix multiplies each candidate's weight by account.EffectiveLoadFactor()
so that, when scores tie, traffic distributes proportionally to capacity.
Existing score-based dynamic adjustments (priority / load / queue / error
rate / TTFT) still apply on top of this multiplier.

Includes two new unit tests covering 15:1 weighting and the LoadFactor=nil
fallback to Concurrency.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
vite build OOMs at default ~960MB heap when running on a 2GB VPS even
with 4GB swap added (cgroup heap limits don't extend to host swap for
the buildkit user namespace). Setting NODE_OPTIONS=--max-old-space-size
=3072 lets the frontend build succeed on the relay box without changing
where we run the build.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Investigation on production showed that /v1/chat/completions and the
common OpenAI gateway path go through selectAccountForModelWithExclusions
-> selectBestAccount, NOT selectByLoadBalance. The previous patch only
fixed the load-balance path; the priority+LRU path still ignored
load_factor entirely, so once all accounts shared the same priority an
N-account pool ended up distributing traffic by LRU instead of by
capacity. Real-world test on a 1×Pro20x + 8×Plus group showed Pro20x
receiving 9.8% of traffic instead of the expected ~65%.

This commit replaces the inner LRU tiebreaker in selectBestAccount with a
weighted reservoir sampling step keyed on account.EffectiveLoadFactor().
Priority remains a hard tier (lower number always wins); compact-tier
ordering remains untouched. Within the same (priority, compactTier) tier,
each candidate is admitted with probability weight/runningTotal, so the
final winner is sampled proportional to LoadFactor.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This is the actual production path when openai_advanced_scheduler_enabled=false
(the default). Previous patches on selectByLoadBalance (advanced scheduler) and
selectBestAccount (legacy fallback) never ran for OpenAI ChatCompletions traffic
in production, which is why the 15:1 load_factor ratio for chong pro20x kept
showing up as ~10-22% uniform-ish distribution.

Selection flow that runs in prod:
  ChatCompletions handler
    -> SelectAccountWithScheduler
    -> selectAccountWithScheduler (advanced=disabled, scheduler==nil)
    -> selectAccountWithLoadAwareness Layer 2 (load-aware, LoadBatchEnabled=true)
    -> shuffleWithinSortGroups   <-- selection randomness lives here
    -> for _, item := range selectionOrder { try acquire }

Patch:
- Add weightedShuffleByLoadFactorWithinSortGroups (OpenAI-only helper).
- Group only by (Priority, LoadRate); within each group, weighted Fisher-Yates
  by EffectiveLoadFactor so position 0 (the position that gets tried first) is
  picked with probability w_i / Sum(w_j).
- When all weights in a segment are equal, fall back to shuffleWithinSortGroups
  to preserve LRU/never-used semantics for legacy uniform-LoadFactor pools.
- Revert selectBestAccount to strict priority+LRU; that path is shared with
  Gemini/Claude flows and the LoadFactor weighting was breaking their tests
  while not running on the prod OpenAI path anyway.

Tests:
- TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor: 1xPro(LF=15) +
  9xPlus(LF=1), 5000 iterations, Pro position-0 ratio within 5% of 15/24,
  each Plus within 3% of 1/24.
- TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping: Priority=1
  accounts strictly precede Priority=2 across 1000 iterations.
- TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack: nil LoadFactor
  with equal Concurrency yields ~50:50.
- Existing TestOpenAISelectAccountWithLoadAwareness_PreferNeverUsed and
  TestOpenAISelectAccountForModelWithExclusions_LeastRecentlyUsed continue
  to pass (LRU preserved when LoadFactor uniform).
…resh paths

Production observation on loadfactor-v3: with sticky_session_ttl_seconds defaulting
to 1h and only one Plus account (Mark4) actively binding sessions, weighted load
distribution within the Plus tier collapses to a single account. Over a 12h window,
chong pro20x correctly received 63.5% (target 65-75%) but the remaining traffic
landed entirely on Mark4 (46/46) while 4 other healthy Plus accounts received 0
chat completions despite being schedulable.

Root cause: SelectAccountWithScheduler -> selectAccountWithLoadAwareness writes
new sticky bindings via setStickySessionAccountID(..., openaiStickySessionTTL)
and refreshes existing ones via refreshStickySessionTTL(..., openaiStickySessionTTL),
both of which hardcode the package-level constant time.Hour. The cfg field
Gateway.OpenAIWS.StickySessionTTLSeconds is exposed and validated, but the load-
aware path never reads it - only BindStickySession (used by Gemini handler) does.

Fix:
- Replace 5 hardcoded openaiStickySessionTTL call sites in the OpenAI load-aware
  set/refresh paths with s.openAIWSSessionStickyTTL(), which already implements
  cfg-aware lookup with the constant as a sane fallback.
- Sites changed:
  - openai_gateway_service.go:1360  selectAccountForModelWithExclusions write
  - openai_gateway_service.go:1419  tryStickySessionHit refresh
  - openai_gateway_service.go:1693  selectAccountWithLoadAwareness sticky-hit refresh
  - openai_gateway_service.go:1770  selectAccountWithLoadAwareness Layer-2 write
  - openai_gateway_service.go:1849  selectAccountWithLoadAwareness Layer-3 write
- BindStickySession (used by Gemini) was already cfg-aware, untouched.

Tests:
- TestOpenAIWSSessionStickyTTL_ConfigOverridesDefault: cfg=600 -> ttl=10min
- TestOpenAIWSSessionStickyTTL_DefaultWhenUnset:        cfg=0   -> ttl=1h
- All 14 existing Sticky* tests continue to pass.

Operational impact:
- Default behavior unchanged (still 1h when env unset).
- Operators can now set GATEWAY_OPENAI_WS_STICKY_SESSION_TTL_SECONDS=600 to
  shorten sticky binding to 10min, allowing weighted load balancing within
  same-priority tier to spread traffic across all healthy accounts.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
…patch billing source preserve

Captured from /opt/relay/sub2api-fork worktree currently running as
hfc/sub2api:agent-platform-adapters-cdf2e2e65593-20260503-155939 (built 15:59 CST 2026-05-03).
Commiting so the prod source state is visible on GitHub for reconciliation
with MarkDonish/sub2api:feat/loadfactor-weighting.

Frontend (no overlap with Marks PR):
- UsageTable + DataTable add pageVerticalScroll prop so the usage log viewport
  stops swallowing wheel events when content fits in the visible area.

Backend (overlaps with Marks billing-source commits, needs reconciliation):
- applyOpenAIMessagesDispatchBillingSource preserves BillingModelSourceRequested
  for Claude requests routed through the OpenAI Messages dispatch path.
- usage_log_repo defaults the customer-facing model dimension to requested_model
  (requestedUsageLogModelExpr) so Claude product tiers do not collapse into
  upstream model labels. Adjusts GetUserModelStats / GetModelStatsWithFilters /
  resolveModelDimensionExpression default and the corresponding test cases.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
… ingress

Some Claude Code CLI versions (notably the multi-turn agentic loop after the
2.1.x auto-update) serialize the model's *in-progress* thinking block as
{"type":"thinking","thinking":"","signature":""} when packaging the previous
turn back into the next request. The native Anthropic /v1/messages API rejects
that block with a 400 schema validation error ("thinking content is required"),
which surfaces to the customer mid-task as an apparent "做到一半就停了" abort.

This adds apicompat.SanitizeAnthropicRequestBody, called as the first step in
both GatewayHandler.Messages/CountTokens (Anthropic native path) and
OpenAIGatewayHandler.Messages (OpenAI translation path), so the same clean
payload is used for ops_error_logs sampling, claude-code-validator, and any
failover that pivots back to a native upstream.

Conservative scrub: only blocks where Type=="thinking" AND Thinking=="" AND
Signature=="" are dropped. Real signed thinking continuations are preserved
untouched, so legitimate extended-thinking multi-turn behavior is unchanged.
The body is parsed via map[string]json.RawMessage so unknown / future top-level
fields (top_k, service_tier, mcp_servers, container, …) round-trip without
loss, and surviving content blocks pass through as raw JSON so block-level
fields not modeled by AnthropicContentBlock are preserved.

Adds Signature field to AnthropicContentBlock and 16 unit tests covering:
empty body, bad JSON, no messages field, string content, no thinking blocks
(idempotent), empty thinking stripped, missing signature key counts as empty,
non-empty thinking preserved, signed thinking preserved, mixed blocks (only
empty thinking dropped), multiple messages, unknown top-level fields, unknown
block fields, system + tools preserved, idempotency, pathological all-empty
content.
… ingress

Some Claude Code CLI versions (notably the multi-turn agentic loop after the
2.1.x auto-update) serialize the model's *in-progress* thinking block as
{"type":"thinking","thinking":"","signature":""} when packaging the previous
turn back into the next request. The native Anthropic /v1/messages API rejects
that block with a 400 schema validation error ("thinking content is required"),
which surfaces to the customer mid-task as an apparent "做到一半就停了" abort.

This adds apicompat.SanitizeAnthropicRequestBody, called as the first step in
both GatewayHandler.Messages/CountTokens (Anthropic native path) and
OpenAIGatewayHandler.Messages (OpenAI translation path), so the same clean
payload is used for ops_error_logs sampling, claude-code-validator, and any
failover that pivots back to a native upstream.

Conservative scrub: only blocks where Type=="thinking" AND Thinking=="" AND
Signature=="" are dropped. Real signed thinking continuations are preserved
untouched, so legitimate extended-thinking multi-turn behavior is unchanged.
The body is parsed via map[string]json.RawMessage so unknown / future top-level
fields (top_k, service_tier, mcp_servers, container, …) round-trip without
loss, and surviving content blocks pass through as raw JSON so block-level
fields not modeled by AnthropicContentBlock are preserved.

Adds Signature field to AnthropicContentBlock and 16 unit tests covering:
empty body, bad JSON, no messages field, string content, no thinking blocks
(idempotent), empty thinking stripped, missing signature key counts as empty,
non-empty thinking preserved, signed thinking preserved, mixed blocks (only
empty thinking dropped), multiple messages, unknown top-level fields, unknown
block fields, system + tools preserved, idempotency, pathological all-empty
content.
Three follow-ups to the empty-thinking sanitize fix shipped in 1eafbba:

1. apicompat/sanitize.go: add a bytes.Contains pre-check on `"thinking"`
   before the json.Unmarshal pair. ~95% of /v1/messages requests carry no
   thinking key at all, so the slow path was pure overhead. False positives
   (user text containing the literal "thinking") fall through to the real
   parse and are still handled correctly.

2. gateway_handler.go + openai_gateway_handler.go: raise the sanErr branch
   from Debug to Warn. Production logging is at Info level, so corrupted
   bodies that the sanitizer cannot parse were being silently forwarded
   with no visibility — the only signal was an upstream Anthropic 400.

3. sanitize_test.go: rewrite the BadJSON test to include the "thinking"
   substring so it actually exercises the parse-error path now that the
   fast path short-circuits non-thinking bodies. Add two new tests:
   FastPath_NoThinkingSubstring (proves the fast path skips parse) and
   FastPath_ThinkingSubstringInUserText (proves false positives still
   yield correct byte-identical output via the slow path).

reqLog already carries request_id + api_key_id + group_id from the
context-bound logger, so opt3 (more triage context in the log line) is
already covered — no code change needed for that one.
Three follow-ups to the empty-thinking sanitize fix shipped in 1eafbba:

1. apicompat/sanitize.go: add a bytes.Contains pre-check on `"thinking"`
   before the json.Unmarshal pair. ~95% of /v1/messages requests carry no
   thinking key at all, so the slow path was pure overhead. False positives
   (user text containing the literal "thinking") fall through to the real
   parse and are still handled correctly.

2. gateway_handler.go + openai_gateway_handler.go: raise the sanErr branch
   from Debug to Warn. Production logging is at Info level, so corrupted
   bodies that the sanitizer cannot parse were being silently forwarded
   with no visibility — the only signal was an upstream Anthropic 400.

3. sanitize_test.go: rewrite the BadJSON test to include the "thinking"
   substring so it actually exercises the parse-error path now that the
   fast path short-circuits non-thinking bodies. Add two new tests:
   FastPath_NoThinkingSubstring (proves the fast path skips parse) and
   FastPath_ThinkingSubstringInUserText (proves false positives still
   yield correct byte-identical output via the slow path).

reqLog already carries request_id + api_key_id + group_id from the
context-bound logger, so opt3 (more triage context in the log line) is
already covered — no code change needed for that one.
gpt-image-2 image_tokens scale primarily with the `quality` parameter
(low ≈ 1k, medium ≈ 2k, high ≈ 4k), not pixel size. The billing layer
already reads Group.ImagePrice1K/2K/4K but the tier classifier only
inspected pixel dimensions and never returned "4K", so the 4K price
slot was unreachable.

normalizeOpenAIImageSizeTier now takes (size, quality) and prefers
quality-based classification, falling back to size dimensions when
quality is empty / "auto" / unrecognized to keep older callers
(gpt-image-1, dall-e flows) working.

Test: existing JSON parse case (size=1024x1024 + quality=high) updated
to expect "4K" — the previous "1K" was a billing bug.

Verified locally: go build ./... + go test -tags unit ./internal/service/...
…ticky TTL fix

merge: openai quota guard + sticky TTL fix + loadfactor weighting (Pro20x dormant)
drift-fix: bring 12 prod-only commits back to main (incl. double-entry billing ledger)
@github-actions

Copy link
Copy Markdown

Thank you for your contribution! Before we can merge this PR, we need you all to sign our Contributor License Agreement (CLA).

To sign, please reply with the following comment:

I have read the CLA Document and I hereby sign the CLA

You only need to sign once — it will be valid for all your future contributions to this project.


I have read the CLA Document and I hereby sign the CLA


0 out of 5 committers have signed the CLA.
@yingcheng1026
@codex
@UnstoppableCurry
❌ @你的 GitHub 用户名
@MarkDonish
yingcheng1026, Codex, 你的 GitHub 用户名 seem not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants