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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ CODEX_PORT=8080
# USE_WEBSOCKET=true

# Codex HTTP TLS 指纹:默认 standard;如需回滚旧行为可设 utls_chrome
# CODEX_TRANSPORT_MODE=standard
# CODEX_TRANSPORT_MODE=utls_chrome

# 下游 HTTP/SSE 保活周期;Go duration,0 关闭。流式端点从首个心跳起提交 SSE:
# Responses/Chat/Images 使用注释,Messages 使用原生 ping;非流式端点使用 HTTP 102。
Expand Down
1 change: 1 addition & 0 deletions .github/scripts/dispatch-pr-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ custom_preserve_paths=(
frontend/src/components/Layout.tsx
frontend/src/components/ui/tooltip.tsx
frontend/src/lib/accountQuickConfig.ts
frontend/src/lib/usageTiming.ts
frontend/src/locales/en.json
frontend/src/locales/zh.json
frontend/src/pages/APIKeyUsagePortal.tsx
Expand Down
27 changes: 25 additions & 2 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ on:
workflow_dispatch:
inputs:
build_version:
description: 'Version label shown in the admin UI, e.g. 2.0.6'
description: 'Version label and manual image tag, e.g. 2.0.6 or dev-abc1234'
required: false
type: string
promote_digest:
description: 'Existing image digest to tag as latest without rebuilding'
required: false
type: string

Expand All @@ -31,6 +35,7 @@ jobs:
uses: actions/checkout@v5

- name: Validate build version
if: ${{ inputs.promote_digest == '' }}
env:
BUILD_VERSION: ${{ inputs.build_version || github.ref_name }}
run: |
Expand All @@ -44,6 +49,7 @@ jobs:
fi

- name: Set up QEMU
if: ${{ inputs.promote_digest == '' }}
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
Expand All @@ -57,16 +63,20 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract Docker metadata
if: ${{ inputs.promote_digest == '' }}
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
tags: |
type=semver,pattern={{version}},value=${{ inputs.build_version || github.ref_name }},enable=${{ inputs.build_version != '' || startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.build_version || github.ref_name }},enable=${{ inputs.build_version != '' || startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest
type=raw,value=${{ inputs.build_version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.build_version != '' }}
type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch }}

- name: Build and push Docker image
if: ${{ inputs.promote_digest == '' }}
uses: docker/build-push-action@v6
with:
context: .
Expand All @@ -80,3 +90,16 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

- name: Promote existing image to latest
if: ${{ inputs.promote_digest != '' }}
env:
SOURCE_DIGEST: ${{ inputs.promote_digest }}
run: |
if ! [[ "$SOURCE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "Invalid image digest" >&2
exit 1
fi
docker buildx imagetools create \
--tag "${REGISTRY}/${IMAGE_NAME}:latest" \
"${REGISTRY}/${IMAGE_NAME}@${SOURCE_DIGEST}"
2 changes: 1 addition & 1 deletion .upstream-release
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v3.1.2
v3.1.3
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## v3.1.3 - 2026-11-04

### Features

- **Model output runaway guard stops degenerate generations before they reach clients (#845, @ggg17226).** An opt-in guard detects periodic repetition, tool-argument delta floods, byte-budget overruns and terminal-frame abuse across Responses, Chat Completions, Anthropic, Gemini and Grok native streams, including downstream WebSocket. On a hit the upstream is cancelled, buffered output is discarded, and the client receives `502 model_output_guard_triggered` (or a WebSocket error frame with close code 1013) without automatic retry or account-health penalty; hits are recorded in `usage_logs` with `upstream_error_kind=model_output_guard`. Limits are configured from the settings page (tool ≤ channel ≤ request), polled every 5 seconds across instances, and default to off. Independently of the guard, SSE framing now enforces an 8 MiB per-line/event cap.

- **Responses continuations can move off a quota-blocked account, and contexts survive restarts (#838, @dundunge).** When the bound account is confirmed quota-limited or auto-paused by a usage threshold, HTTP and WebSocket continuations whose context can be replayed are rebound to another eligible account; non-migratable references, encrypted compaction state or incomplete tool pairs still return an error. With the Memory cache driver and a SQLite main database, response contexts are also written to a separate `response-context.db` (deduplicated, 48-hour sliding retention, 2 GiB cap by default) and recovered after L1 eviction or restart; `RESPONSE_CONTEXT_STORE_PATH=off` disables it. The file holds conversation content and is not included in main-database backups.

- **New `reset_5h_first` scheduler mode and per-account scheduled connection tests (#838, @dundunge).** The optional mode prefers available Codex accounts whose observed 5h window resets soonest, within existing priority and health tiers. Accounts can schedule weekday/weekend connection tests (off by default); enabled tests send real upstream requests, do not retry, and do not backfill missed slots. See `docs/RESET_5H_PRIORITY.md`.

- **Antigravity quota gates are isolated per model family, with optional expiry-first scheduling (#848, requested by @yanhao98; #850).** Quota buckets and models are grouped into Gemini, Claude+GPT and Argon families by ID prefix. An exhausted `3p-weekly` bucket no longer blocks Gemini requests on the same account; the whole account is gated only when every recognised family is exhausted, and selection, 429 `Retry-After` and quota pressure all follow the requested model's family. Unknown buckets and models keep the previous account-wide behavior. The new `expiry_priority_enabled` setting (off by default, 1–168h window, default 24h) lets round-robin selection prefer accounts whose weekly bucket for the requested family resets soon with at least 5% remaining.

- **Intelligence checks can back off adaptively and confirm recovery progressively (#849, @bikeread).** The opt-in mode backs off auto-paused accounts exponentially up to a configurable cap, switches to short confirmation intervals after the first pass, and resumes traffic only after the existing recovery threshold. Backoff state persists across restarts, and credentials rejected upstream pause automatic checks until they are updated. See `docs/INTELLIGENCE_BACKOFF.md`.

- **The API key self-service usage page shows concurrency and richer request logs (#847, @bxb1337).** The page now displays the key's channel scope and current concurrency (refreshed every 5 seconds, paused when hidden), and request logs add client IP and User-Agent, reasoning effort, separate reasoning tokens, cache read/write, timing and throughput, sharing display components with the admin usage page.

### Fixes

- **Claude account and shared group editors convert auto-pause thresholds from percent (#843, reported by @xpzhang).** The Claude per-account scheduler dialog and the shared group manager labelled thresholds as percentages but submitted the raw value, so entering 95 failed with "must be between 0 and 1" and saved ratios were displayed as decimals. Both now convert percent to ratio on save and back on display, validate 0–100, and accept decimals. Values previously saved as `0.95` are correct and now display as 95.

- **Codex maintenance requests and OAuth refresh share the conversation identity (#846, @bxb1337).** The separate client-identity toggle is removed; maintenance requests, model-manifest discovery and token refresh use the same identity resolution and per-account overrides as conversations. Code exchange no longer sends the fixed legacy `codex-cli/0.91.0` UA, refresh uses JSON with `refresh_reason`, and an unresolvable identity configuration now returns an error instead of falling back to a built-in identity.

- **OAuth token exchange and refresh use the Chrome TLS fingerprint, and device profiles are stable by default (#839, @frank8ai).** Refresh-token and admin authorization-code requests no longer use the Go standard TLS stack. `STABILIZE_DEVICE_PROFILE` now defaults to `true`, so an account's device/system profile no longer drifts between requests; set it to `false` to restore the previous behavior. The example configuration recommends `CODEX_TRANSPORT_MODE=utls_chrome`; the code default is unchanged.

- **Antigravity preserves deferred Responses tool identities (#842, @frank8ai).** Namespace, custom and tool-search declarations, search-loaded tools, call history and forced tool choices are bridged to flat Gemini functions and restored to their original identities in JSON, native SSE and buffered SSE responses. Streamed function-call updates merge only by matching call ID, so distinct calls with identical arguments are no longer collapsed.

- **Context errors, 413 diagnostics, model manifests and pricing (#838, @dundunge).** The missing-context error code changes from `response_context_unavailable` to `previous_response_not_found`, prompting clients to resend the full context. Oversized-request 413 responses report the limit, detection stage and observed size. Merging live upstream manifests with account snapshots no longer clears capability fields for unobserved accounts. `gpt-6.1-terra` gains built-in pricing.

- **Dependency security update.** `golang.org/x/net` moves to v0.62.0 for GO-2026-6860.

## v3.1.2 - 2026-10-28

### Features
Expand Down
51 changes: 48 additions & 3 deletions admin/antigravity_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type antigravitySettingsResponse struct {
QuotaRefreshEnabled bool `json:"quota_refresh_enabled"`
QuotaRefreshIntervalMinutes int `json:"quota_refresh_interval_minutes"`
QuotaGateEnabled bool `json:"quota_gate_enabled"`
ExpiryPriorityEnabled bool `json:"expiry_priority_enabled"`
ExpiryPriorityHorizonHours int `json:"expiry_priority_horizon_hours"`
ConfigRevision int64 `json:"config_revision"`
Choices []proxy.AntigravityRedirectChoice `json:"choices"`
}
Expand All @@ -47,6 +49,8 @@ func buildAntigravitySettingsResponse(settings auth.AntigravitySettings) antigra
QuotaRefreshEnabled: settings.GetQuotaRefreshEnabled(),
QuotaRefreshIntervalMinutes: settings.GetQuotaRefreshIntervalMinutes(),
QuotaGateEnabled: settings.GetQuotaGateEnabled(),
ExpiryPriorityEnabled: settings.GetExpiryPriorityEnabled(),
ExpiryPriorityHorizonHours: settings.GetExpiryPriorityHorizonHours(),
ConfigRevision: settings.ConfigRevision,
Choices: proxy.AntigravityRedirectChoices(),
}
Expand All @@ -71,12 +75,18 @@ type antigravitySettingsPatch struct {
quotaRefreshIntervalPresent bool
quotaGateEnabled *bool
quotaGateEnabledPresent bool

expiryPriorityEnabled *bool
expiryPriorityEnabledPresent bool
expiryPriorityHorizonHours *int
expiryPriorityHorizonPresent bool
}

// empty 报告 patch 是否未包含任何更新字段。
func (p antigravitySettingsPatch) empty() bool {
return p.modelRedirects == nil && p.redirectOverridesEffort == nil && p.exposeThoughts == nil &&
!p.quotaRefreshEnabledPresent && !p.quotaRefreshIntervalPresent && !p.quotaGateEnabledPresent
!p.quotaRefreshEnabledPresent && !p.quotaRefreshIntervalPresent && !p.quotaGateEnabledPresent &&
!p.expiryPriorityEnabledPresent && !p.expiryPriorityHorizonPresent
}

// isJSONNull 报告原始 JSON 字段是否显式为 null 字面量。
Expand Down Expand Up @@ -142,6 +152,32 @@ func parseAntigravitySettingsPatch(body []byte) (antigravitySettingsPatch, error
return patch, err
}
patch.quotaGateEnabled = value
case "expiry_priority_enabled":
patch.expiryPriorityEnabledPresent = true
if isJSONNull(raw) {
patch.expiryPriorityEnabled = nil
continue
}
value, err := decodeAntigravityBool(raw, "expiry_priority_enabled")
if err != nil {
return patch, err
}
patch.expiryPriorityEnabled = value
case "expiry_priority_horizon_hours":
patch.expiryPriorityHorizonPresent = true
if isJSONNull(raw) {
patch.expiryPriorityHorizonHours = nil
continue
}
var hours int
if err := json.Unmarshal(raw, &hours); err != nil {
return patch, fmt.Errorf("expiry_priority_horizon_hours 必须是整数")
}
if hours < auth.AntigravityExpiryPriorityHorizonMinHours || hours > auth.AntigravityExpiryPriorityHorizonMaxHours {
return patch, fmt.Errorf("expiry_priority_horizon_hours 必须在 [%d, %d] 之间",
auth.AntigravityExpiryPriorityHorizonMinHours, auth.AntigravityExpiryPriorityHorizonMaxHours)
}
patch.expiryPriorityHorizonHours = &hours
case "quota_refresh_interval_minutes":
patch.quotaRefreshIntervalPresent = true
if isJSONNull(raw) {
Expand Down Expand Up @@ -210,6 +246,14 @@ func mergeAntigravitySettingsPatch(base auth.AntigravitySettings, patch antigrav
merged.QuotaGateEnabled = restore.QuotaGateEnabled
restored = true
}
// 临期优先缺省关闭:混布窗口里被旧版本整包覆盖丢失只会回到「关」,不像门禁止血
// 开关那样会被默认值无痕反转,因此不参与回写恢复。
if patch.expiryPriorityEnabledPresent {
merged.ExpiryPriorityEnabled = patch.expiryPriorityEnabled
}
if patch.expiryPriorityHorizonPresent {
merged.ExpiryPriorityHorizonHours = patch.expiryPriorityHorizonHours
}
return merged, restored
}

Expand All @@ -230,7 +274,7 @@ func (h *Handler) UpdateAntigravitySettings(c *gin.Context) {
return
}
if patch.empty() {
writeError(c, http.StatusBadRequest, "缺少 model_redirects、redirect_overrides_effort、expose_thoughts、quota_refresh_enabled、quota_refresh_interval_minutes 或 quota_gate_enabled 字段")
writeError(c, http.StatusBadRequest, "缺少 model_redirects、redirect_overrides_effort、expose_thoughts、quota_refresh_enabled、quota_refresh_interval_minutes、quota_gate_enabled、expiry_priority_enabled 或 expiry_priority_horizon_hours 字段")
return
}
if h.db == nil {
Expand Down Expand Up @@ -295,9 +339,10 @@ func (h *Handler) UpdateAntigravitySettings(c *gin.Context) {

// 本机按 revision 单调应用(含 revision 回退检测与基线重建)。
outcome := auth.ApplyConfiguredAntigravitySettings(applied)
log.Printf("设置已更新: antigravity_config redirects=%d overrides_effort=%v expose_thoughts=%v quota_refresh_enabled=%v quota_refresh_interval_minutes=%d quota_gate_enabled=%v config_revision=%d apply=%s",
log.Printf("设置已更新: antigravity_config redirects=%d overrides_effort=%v expose_thoughts=%v quota_refresh_enabled=%v quota_refresh_interval_minutes=%d quota_gate_enabled=%v expiry_priority_enabled=%v expiry_priority_horizon_hours=%d config_revision=%d apply=%s",
len(applied.ModelRedirects), applied.RedirectOverridesEffort, applied.ExposeThoughts,
applied.GetQuotaRefreshEnabled(), applied.GetQuotaRefreshIntervalMinutes(), applied.GetQuotaGateEnabled(),
applied.GetExpiryPriorityEnabled(), applied.GetExpiryPriorityHorizonHours(),
applied.ConfigRevision, outcome)
// quota_refresh_enabled / interval 可能已变化:立即唤醒配额巡检调度器按新配置重算槽位,
// 不必等下一轮自然到点。
Expand Down
53 changes: 53 additions & 0 deletions admin/antigravity_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/codex2api/auth"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -223,3 +224,55 @@ func TestAntigravitySettingsPUTRestoresQuotaFieldsDroppedByOldWriter(t *testing.
t.Fatalf("persisted=%q err=%v", raw, err)
}
}

// TestAntigravitySettingsPUTExpiryPriority 覆盖临期优先开关与时间窗的保存、范围校验与 null 重置。
func TestAntigravitySettingsPUTExpiryPriority(t *testing.T) {
gin.SetMode(gin.TestMode)
previous := auth.ConfiguredAntigravitySettings()
t.Cleanup(func() { auth.SetConfiguredAntigravitySettings(previous) })
auth.SetConfiguredAntigravitySettings(auth.AntigravitySettings{})
db := newTestAdminDB(t)
handler := &Handler{db: db}
router := gin.New()
router.PUT("/api/admin/settings/antigravity", handler.UpdateAntigravitySettings)

put := func(body string) (int, antigravitySettingsResponse) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings/antigravity", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
var payload antigravitySettingsResponse
_ = json.Unmarshal(rec.Body.Bytes(), &payload)
return rec.Code, payload
}

code, payload := put(`{"expiry_priority_enabled":true}`)
if code != http.StatusOK || !payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 24 {
t.Fatalf("enable: code=%d payload=%+v", code, payload)
}
if enabled, horizon := auth.AntigravityExpiryPriority(); !enabled || horizon != 24*time.Hour {
t.Fatalf("runtime expiry priority = %v %v", enabled, horizon)
}

code, payload = put(`{"expiry_priority_horizon_hours":12}`)
if code != http.StatusOK || !payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 12 {
t.Fatalf("horizon: code=%d payload=%+v", code, payload)
}
if raw, err := db.GetAntigravityConfigRaw(context.Background()); err != nil ||
!strings.Contains(raw, `"expiry_priority_enabled":true`) || !strings.Contains(raw, `"expiry_priority_horizon_hours":12`) {
t.Fatalf("persisted=%q err=%v", raw, err)
}
for _, body := range []string{`{"expiry_priority_horizon_hours":0}`, `{"expiry_priority_horizon_hours":169}`, `{"expiry_priority_enabled":"yes"}`} {
if code, _ := put(body); code != http.StatusBadRequest {
t.Fatalf("PUT %s status=%d, want 400", body, code)
}
}

code, payload = put(`{"expiry_priority_enabled":null}`)
if code != http.StatusOK || payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 12 {
t.Fatalf("null reset: code=%d payload=%+v", code, payload)
}
if enabled, _ := auth.AntigravityExpiryPriority(); enabled {
t.Fatal("null must reset expiry priority to disabled")
}
}
Loading
Loading