diff --git a/.github/workflows/cache.yaml b/.github/workflows/cache.yaml new file mode 100644 index 00000000..aec11155 --- /dev/null +++ b/.github/workflows/cache.yaml @@ -0,0 +1,129 @@ +name: Build cache + +# Actions caches are scoped to the ref that saved them: pull request and tag runs cannot read each +# other's caches, but every ref can read the default branch's. After each merge this workflow +# compiles (without testing) on main, so the next pull request and release tag restore an almost +# current build and recompile only what changed. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-cache + cancel-in-progress: true + +jobs: + go: + name: Go caches + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + # The keys match the CI jobs that restore them. + - name: Restore Go build cache + id: build-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }} + restore-keys: | + go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}- + go-build-${{ runner.os }}- + - name: Compile packages and tests + run: | + mkdir -p web/dist && touch web/dist/.keep + go build ./... + go vet ./... + - name: Save Go build cache + if: steps.build-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ steps.build-cache.outputs.cache-primary-key }} + - name: Clear the normal build cache + run: go clean -cache + - name: Restore Go race build cache + id: race-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Paths are part of the cache version and must match the CI restore exactly. + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: go-race-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }} + restore-keys: | + go-race-${{ runner.os }}-${{ hashFiles('go.sum') }}- + go-race-${{ runner.os }}- + - name: Compile race-instrumented tests + # -run '^$' builds and links every test binary without running a test. + run: go test -race -run '^$' ./... > /dev/null + - name: Save Go race build cache + if: steps.race-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ steps.race-cache.outputs.cache-primary-key }} + + containers: + name: Container caches (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + id: buildx + - name: Cache BuildKit mounts + id: mount-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: buildkit-mounts + key: buildkit-mounts-${{ matrix.arch }}-${{ hashFiles('go.sum', 'web/pnpm-lock.yaml') }}-${{ hashFiles('**/*.go', 'web/src/**') }} + restore-keys: | + buildkit-mounts-${{ matrix.arch }}-${{ hashFiles('go.sum', 'web/pnpm-lock.yaml') }}- + buildkit-mounts-${{ matrix.arch }}- + - name: Inject BuildKit mounts + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 + with: + builder: ${{ steps.buildx.outputs.name }} + dockerfile: Dockerfile + cache-dir: buildkit-mounts + skip-extraction: ${{ steps.mount-cache.outputs.cache-hit }} + - name: Build image layers + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/${{ matrix.arch }} + push: false + build-args: | + VERSION=0.1.0-dev + REVISION=${{ github.sha }} + SOURCE_URL=${{ github.server_url }}/${{ github.repository }} + cache-from: type=gha,scope=container-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=container-${{ matrix.arch }} + provenance: false diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49d0636d..1dcba726 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,6 +38,20 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + cache: false + # Build outputs are keyed by source, so each commit restores the closest earlier build and + # recompiles only what changed. Only the build part saves, so the cache holds a full build. + - name: Restore Go build cache + id: go-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }} + restore-keys: | + go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}- + go-build-${{ runner.os }}- - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.12.2 @@ -68,6 +82,14 @@ jobs: - name: Verify key migration with encrypted credentials if: matrix.part == 'build' run: SUBLANE_TEST_MIGRATION_BINARY="$PWD/bin/sublane" node --test --test-name-pattern='real CLI' scripts/separate-key.test.mjs + - name: Save Go build cache + if: matrix.part == 'build' && steps.go-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ steps.go-cache.outputs.cache-primary-key }} - name: Validate workflow and shell scripts if: matrix.part == 'lint' run: | @@ -86,7 +108,7 @@ jobs: strategy: fail-fast: false matrix: - group: [gateway-server-1, gateway-server-2, gateway-server-3, gateway-server-4, gateway-server-5, other-1, other-2, other-3] + group: [gateway-server-1, gateway-server-2, gateway-server-3, gateway-server-4, gateway-server-5, other-1, other-2, other-3, other-4] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -94,6 +116,20 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + cache: false + # Race-instrumented objects differ from normal builds, so they keep their own cache. The first + # shard compiles the gateway and server with every dependency and saves for the others. + - name: Restore Go race build cache + id: go-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: go-race-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }} + restore-keys: | + go-race-${{ runner.os }}-${{ hashFiles('go.sum') }}- + go-race-${{ runner.os }}- - name: Test with race detector shell: bash env: @@ -101,26 +137,36 @@ jobs: run: | # Shards partition by test name and package; each final shard is a complement so # examples, fuzz seeds, future test names and new packages are always tested. - heavy_a='/internal/(allocations|accounts)$' + heavy_a='/internal/allocations$' heavy_b='/internal/(backup|auth|alerts)$' + heavy_c='/internal/accounts$' case "$TEST_GROUP" in gateway-server-1) go test -race -timeout=20m -run '^Test[A-B]' ./internal/gateway ./internal/server ;; gateway-server-2) go test -race -timeout=20m -run '^Test[C-G]' ./internal/gateway ./internal/server ;; gateway-server-3) go test -race -timeout=20m -run '^Test[H-N]' ./internal/gateway ./internal/server ;; gateway-server-4) go test -race -timeout=20m -run '^Test[O-R]' ./internal/gateway ./internal/server ;; gateway-server-5) go test -race -timeout=20m -skip '^Test[A-R]' ./internal/gateway ./internal/server ;; - other-1|other-2|other-3) + other-1|other-2|other-3|other-4) all=$(go list ./... | grep -vE '/internal/(gateway|server)$') case "$TEST_GROUP" in other-1) package_list=$(grep -E "$heavy_a" <<< "$all") ;; other-2) package_list=$(grep -E "$heavy_b" <<< "$all") ;; - other-3) package_list=$(grep -vE "$heavy_a|$heavy_b" <<< "$all") ;; + other-3) package_list=$(grep -vE "$heavy_a|$heavy_b|$heavy_c" <<< "$all") ;; + other-4) package_list=$(grep -E "$heavy_c" <<< "$all") ;; esac mapfile -t packages <<< "$package_list" go test -race -timeout=20m "${packages[@]}" ;; *) echo "unknown test group: $TEST_GROUP" >&2; exit 1 ;; esac + - name: Save Go race build cache + if: matrix.group == 'gateway-server-1' && steps.go-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ steps.go-cache.outputs.cache-primary-key }} containers: name: Container (${{ matrix.arch }}) @@ -145,6 +191,26 @@ jobs: with: node-version-file: .node-version - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + id: buildx + # Layer caching cannot keep RUN --mount=type=cache directories, so every image rebuilt Go and + # frontend dependencies from scratch. Persist those mounts per architecture and source state. + - name: Cache BuildKit mounts + id: mount-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: buildkit-mounts + key: buildkit-mounts-${{ matrix.arch }}-${{ hashFiles('go.sum', 'web/pnpm-lock.yaml') }}-${{ hashFiles('**/*.go', 'web/src/**') }} + restore-keys: | + buildkit-mounts-${{ matrix.arch }}-${{ hashFiles('go.sum', 'web/pnpm-lock.yaml') }}- + buildkit-mounts-${{ matrix.arch }}- + - name: Inject BuildKit mounts + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 + with: + builder: ${{ steps.buildx.outputs.name }} + # The mounts are discovered from the Dockerfile, so new cache mounts are persisted too. + dockerfile: Dockerfile + cache-dir: buildkit-mounts + skip-extraction: ${{ steps.mount-cache.outputs.cache-hit }} - name: Build image for the native runner uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: diff --git a/PRODUCT.md b/PRODUCT.md index b722d2c3..3483d90b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -107,7 +107,11 @@ Gateway admission allows model changes with reasoning, compaction and reconstruc Resource groups offer optional session stickiness, disabled by default. When enabled, requests that carry no gateway session derive a conversation key from client conversation headers, Codex turn metadata, Claude Code session metadata or, as a fallback, a hash of the system prompt and first user message, following CLIProxyAPI's session-affinity order. Derived keys create one-hour preferences in their own namespace, so they never read or rewrite an explicit binding: the preferred resource ranks first only after every policy, quota, allowance, cooldown and capacity check, and unavailable resources fall back to normal scheduling. Preferences may hold at most a quarter of the shared affinity table, are skipped rather than rejected when it is full, and are evicted first when an explicit conversation needs a slot. Subagents identified by Codex parent threads, Claude parent sessions or `X-Parent-Session-Id` prefer their parent's resource through read-only lookups of the parent's explicit binding or preference. Claude Code subagents share their main session key; a conversation keeps whichever raw or normalized key already holds its binding whether the setting is on or off; the setting only decides whether an unbound conversation creates a normalized binding. Explicit sessions keep the existing strict binding rules. -Resource groups also offer quota failover, enabled by default for new and existing groups and switchable per group. When enabled, an existing Responses conversation whose bound account has exhausted its quota moves to another admitted account of the same provider instead of failing. The binding records 16-byte fingerprints (at most 4,096) of every encrypted reasoning or compaction item present at the move, and those items are withheld from the new account on that and every later turn, so only readable transcript and the new account's own state reach it. Requests that reference provider-held state (`previous_response_id`, `conversation`, `container` or `item_reference`) never move, and disablement, cooldown, saturation or model mismatch still never move a conversation. +Resource groups also offer account failover, enabled by default for new and existing groups and switchable per group. When enabled, an existing Responses conversation moves to another admitted account of the same provider instead of failing when its bound account has exhausted its quota or allowance, is cooling down for five minutes or more, is disabled, needs reauthorization, was deleted or left the pool; if no account can take it, the original account's error is returned. The binding records 16-byte fingerprints (at most 4,096) of every encrypted reasoning or compaction item present at the move, and those items are withheld from the new account on that and every later turn, so only readable transcript and the new account's own state reach it. Requests that reference provider-held state (`previous_response_id`, `conversation`, `container` or `item_reference`) never move, and saturation, short cooldowns or model mismatch never move a conversation. Groups with failover off keep the strict rule that conversations never switch accounts. + +Each upstream account presents one client fingerprint, following CLIProxyAPI. Claude accounts keep their durable device ID and pin one device profile (User-Agent and X-Stainless OS, architecture, runtime and package versions) per account. Codex accounts pin the platform and terminal of the first native Codex client that uses them (stored on the account) and present it with SubLane's managed Codex version to every later client, whatever machine or tool it runs on; accounts never seen by a native client keep the bare version agent. Different accounts may present different fingerprints. + +Provider requests whose connection fails before any byte is sent (DNS, TCP dial or proxy CONNECT) are retried up to twice after 250 ms and 1 s on the same account; failures after a request may have reached the provider are never replayed. API channels can be renamed and given a replacement key in place at `/channels`; leaving the key blank keeps the stored secret. Base URL, platform and protocol remain fixed so a stored key never reaches another upstream; those changes still require a separate channel. Edits retain the channel ID, group assignments, limits and request history; a key change resets verification and invalidates the model catalog in the same transaction. Resource groups can be deleted when no active personal key or resource allowance references them. Deletion retires the group: routing, grants, resources, model policy and bindings are removed in one audited transaction, while request history and usage aggregates keep their workspace scope. Retired groups keep their names; uniqueness applies only to active groups, so the name can be reused. diff --git a/internal/accounts/credential.go b/internal/accounts/credential.go index e6000aa4..7d0e1a9f 100644 --- a/internal/accounts/credential.go +++ b/internal/accounts/credential.go @@ -10,19 +10,21 @@ import ( // Credential is internal secret material. HTTP responses must use Account instead. type Credential struct { - BaseURL string `json:"base_url,omitempty"` - APIPlatform string `json:"api_platform,omitempty"` - APIProtocol string `json:"api_protocol,omitempty"` - ProxyURL string `json:"-"` - Provider string `json:"provider,omitempty"` - Metadata map[string]json.RawMessage `json:"metadata,omitempty"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token,omitempty"` - AccountID string `json:"account_id"` - Email string `json:"email,omitempty"` - Plan string `json:"plan,omitempty"` - ExpiresAt int64 `json:"expires_at"` + BaseURL string `json:"base_url,omitempty"` + APIPlatform string `json:"api_platform,omitempty"` + APIProtocol string `json:"api_protocol,omitempty"` + ProxyURL string `json:"-"` + // ClientPlatform is the account's pinned client platform (for example "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1"). + ClientPlatform string `json:"-"` + Provider string `json:"provider,omitempty"` + Metadata map[string]json.RawMessage `json:"metadata,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + AccountID string `json:"account_id"` + Email string `json:"email,omitempty"` + Plan string `json:"plan,omitempty"` + ExpiresAt int64 `json:"expires_at"` // Keep rejection with the encrypted token so a restart cannot make it eligible for fallback. Rejected bool `json:"access_token_rejected,omitempty"` } diff --git a/internal/accounts/platform.go b/internal/accounts/platform.go new file mode 100644 index 00000000..c688232b --- /dev/null +++ b/internal/accounts/platform.go @@ -0,0 +1,35 @@ +package accounts + +import ( + "context" + "regexp" + + "github.com/murongg/SubLane/internal/storage/db" +) + +// codexClientAgent matches a native Codex User-Agent: +// "/ ( ; ) [ ()]". +// Only the platform and terminal are kept; the version always comes from SubLane's version policy. +var codexClientAgent = regexp.MustCompile(`^(?:codex_cli_rs|codex-tui|codex_exec|codex_vscode)/[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.+-]{0,24} (\([A-Za-z][A-Za-z0-9 ._-]{0,48} [0-9][0-9A-Za-z._-]{0,24}; [A-Za-z0-9_]{1,16}\)(?: [A-Za-z0-9][A-Za-z0-9._+-]{0,40}(?:/[A-Za-z0-9._+-]{1,40})?)?)`) + +// CodexClientPlatform extracts the platform portion of a native Codex User-Agent, or "". +func CodexClientPlatform(userAgent string) string { + if len(userAgent) > 512 { + return "" + } + match := codexClientAgent.FindStringSubmatch(userAgent) + if match == nil { + return "" + } + return match[1] +} + +// LearnClientPlatform pins an account's client platform from its first native client. Later +// observations never replace it, so every client sharing the account presents one fingerprint. +func (s *Service) LearnClientPlatform(ctx context.Context, id, platform string) error { + if platform == "" || len(platform) > 160 { + return nil + } + _, err := s.queries.LearnClientPlatform(ctx, db.LearnClientPlatformParams{ID: id, TenantID: s.tenantID, ClientPlatform: &platform}) + return err +} diff --git a/internal/accounts/platform_test.go b/internal/accounts/platform_test.go new file mode 100644 index 00000000..09f599d9 --- /dev/null +++ b/internal/accounts/platform_test.go @@ -0,0 +1,38 @@ +package accounts + +import ( + "context" + "testing" + "time" +) + +func TestCodexClientPlatformKeepsOnlyNativePlatform(t *testing.T) { + for agent, want := range map[string]string{ + "codex_cli_rs/0.46.0 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1": "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1", + "codex-tui/0.154.0 (Windows 10.0.26100; x86_64) WindowsTerminal (codex-tui)": "(Windows 10.0.26100; x86_64) WindowsTerminal", + "codex_exec/0.50.0 (Ubuntu 24.04; x86_64)": "(Ubuntu 24.04; x86_64)", + "claude-cli/2.1.287 (external, cli)": "", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "", + "codex_cli_rs/0.46.0": "", + } { + if got := CodexClientPlatform(agent); got != want { + t.Errorf("%q: got %q, want %q", agent, got, want) + } + } +} + +func TestLearnedClientPlatformIsPinnedAndReachesExecution(t *testing.T) { + s, account, _ := refreshFixture(t, "codex", time.Hour) + ctx := context.Background() + if err := s.LearnClientPlatform(ctx, account.ID, "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1"); err != nil { + t.Fatal(err) + } + // A later client on another machine must not replace the account's fingerprint. + if err := s.LearnClientPlatform(ctx, account.ID, "(Windows 10.0.26100; x86_64) WindowsTerminal"); err != nil { + t.Fatal(err) + } + credential, err := s.Prepare(ctx, account.ID, nil) + if err != nil || credential.ClientPlatform != "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1" { + t.Fatalf("pinned platform missing from execution credential: %q %v", credential.ClientPlatform, err) + } +} diff --git a/internal/accounts/refresh.go b/internal/accounts/refresh.go index a3b22eda..ea48aca5 100644 --- a/internal/accounts/refresh.go +++ b/internal/accounts/refresh.go @@ -73,6 +73,9 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } } + if row.ClientPlatform != nil { + credential.ClientPlatform = *row.ClientPlatform + } if row.ProxyID != nil { credential.ProxyURL, err = s.proxyURL(ctx, *row.ProxyID) if err != nil { @@ -142,7 +145,7 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } delete(s.refreshes, id) - updated.ProxyURL = credential.ProxyURL + updated.ProxyURL, updated.ClientPlatform = credential.ProxyURL, credential.ClientPlatform return updated, nil } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 2351853f..a425da5f 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -212,6 +212,7 @@ ALTER TABLE accounts DROP COLUMN reset_check_at; ALTER TABLE accounts DROP COLUMN usage_revision; ALTER TABLE account_affinity DROP COLUMN rebound; ALTER TABLE account_affinity DROP COLUMN model; +ALTER TABLE accounts DROP COLUMN client_platform; ALTER TABLE account_groups DROP COLUMN quota_failover; ALTER TABLE account_affinity DROP COLUMN foreign_state; DROP INDEX account_groups_active_name; diff --git a/internal/gateway/failover.go b/internal/gateway/failover.go index a66a8f0a..38d39969 100644 --- a/internal/gateway/failover.go +++ b/internal/gateway/failover.go @@ -4,13 +4,37 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" + + "github.com/murongg/SubLane/internal/accounts" + "github.com/murongg/SubLane/internal/allocations" ) -// Quota failover lets a resource group move an existing Responses conversation off a -// quota-exhausted account. Encrypted reasoning and compaction state belongs to the account that +// Quota failover lets a resource group move an existing Responses conversation off an account that +// can no longer serve it (see failoverCause). Encrypted reasoning and compaction state belongs to the account that // produced it, so the binding remembers fingerprints of every encrypted item present when it moved // and those items are withheld from the new account on this and later turns. +// failoverCooldownSeconds separates short rate limits, which a conversation waits out on its own +// account, from long ones such as an exhausted usage window. +const failoverCooldownSeconds = 300 + +// failoverCause reports whether an account rejection is lasting enough to move a conversation: +// exhausted quota or allowance, a long cooldown, or an account that is disabled, needs +// reauthorization, was deleted or left the pool. Saturation and short cooldowns never move it. +func failoverCause(err error) bool { + var quota *QuotaError + var cooling *CoolingError + switch { + case errors.As(err, "a): + return true + case errors.As(err, &cooling): + return cooling.RetryAfter >= failoverCooldownSeconds + } + return errors.Is(err, accounts.ErrDisabled) || errors.Is(err, accounts.ErrReauthorize) || errors.Is(err, accounts.ErrNotFound) || + errors.Is(err, ErrAffinityUnavailable) || errors.Is(err, allocations.ErrQuota) +} + const ( stateDigestSize = 16 // Bounds the fingerprint column (migration 035 allows 65,536 bytes). diff --git a/internal/gateway/failover_test.go b/internal/gateway/failover_test.go index 638422bc..ed522314 100644 --- a/internal/gateway/failover_test.go +++ b/internal/gateway/failover_test.go @@ -134,3 +134,81 @@ func TestQuotaFailoverRefusesServerHeldState(t *testing.T) { t.Fatal("stored provider state moved accounts", err) } } + +func TestFailoverMovesOffLastingUnavailability(t *testing.T) { + for _, test := range []struct { + name string + break_ func(t *testing.T, s *Service, id string) + }{ + {"long cooldown", func(t *testing.T, s *Service, id string) { + s.health[id].CooldownUntil = s.now().Add(time.Hour).Unix() + }}, + {"disabled", func(t *testing.T, s *Service, id string) { + if _, err := s.accounts.SetEnabled(context.Background(), id, false); err != nil { + t.Fatal(err) + } + }}, + {"removed from pool", func(t *testing.T, s *Service, id string) { + if _, err := s.db.Exec("DELETE FROM group_accounts WHERE account_id=?", id); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + s, first, other, _ := failoverGateway(t, true) + test.break_(t, s, first) + if err := openTurn(t, s, strings.Join([]string{failoverUser, failoverOld, failoverReply, failoverFollowUp}, ",")); err != nil || boundAccount(t, s) != other { + t.Fatal("conversation stayed on an unusable account", err) + } + }) + } +} + +func TestFailoverWaitsOutShortCooldown(t *testing.T) { + s, first, _, _ := failoverGateway(t, true) + s.health[first].CooldownUntil = s.now().Add(time.Minute).Unix() + err := openTurn(t, s, failoverFollowUp) + var cooling *CoolingError + if !errors.As(err, &cooling) || boundAccount(t, s) != first { + t.Fatal("short cooldown moved the conversation", err) + } +} + +func TestCodexClientPlatformIsPinnedPerAccount(t *testing.T) { + var agents []string + var mu sync.Mutex + s, _ := codexGateway(t, transportFunc(func(r *http.Request) (*http.Response, error) { + if strings.HasSuffix(r.URL.Path, "/responses") { + mu.Lock() + agents = append(agents, r.Header.Get("User-Agent")) + mu.Unlock() + } + return syntheticStream(), nil + })) + for _, agent := range []string{"codex_cli_rs/0.46.0 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1", "codex_cli_rs/0.50.0 (Windows 10.0.26100; x86_64) WindowsTerminal", "SyntheticChatClient/1.0"} { + response, err := s.Open(context.Background(), 1, 1, []byte(`{"model":"synthetic-model","input":"synthetic"}`), http.Header{"User-Agent": {agent}}, Responses) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, response.Body) + response.Body.Close() + } + mu.Lock() + defer mu.Unlock() + if len(agents) != 3 { + t.Fatal("unexpected upstream requests", agents) + } + for _, agent := range agents { + if !strings.HasSuffix(agent, " (Mac OS 15.5.0; arm64) iTerm.app/3.6.1") || !strings.HasPrefix(agent, "codex_cli_rs/") { + t.Fatal("clients on one account presented different fingerprints", agents) + } + } +} + +// strictBinding turns failover off for a group whose test asserts the strict no-move rule. +func strictBinding(t *testing.T, s *Service, groupID int64) { + t.Helper() + if _, err := s.db.Exec("UPDATE account_groups SET quota_failover=0 WHERE id=?", groupID); err != nil { + t.Fatal(err) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 561acb34..7970c03e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "sync" "time" @@ -235,6 +236,14 @@ func (s *Service) Open(ctx context.Context, userID, groupID int64, raw []byte, h if err != nil { return nil, err } + if entry.record.Provider == "codex" { + // The first native Codex client pins the account's platform; later clients on any machine reuse it. + if platform := accounts.CodexClientPlatform(headers.Get("User-Agent")); platform != "" { + if learnErr := s.accounts.LearnClientPlatform(ctx, id, platform); learnErr != nil { + entry.log(slog.LevelWarn, "Unable to pin client platform", "client_platform_failed") + } + } + } outgoing := headers.Clone() if outgoing == nil { outgoing = make(http.Header) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 6cb07842..e64cdb7d 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -108,6 +108,7 @@ func TestGatewayKeepsAccountAffinityAndBoundsActiveRequests(t *testing.T) { if _, err := service.SetEnabled(ctx, mapping[selected], false); err != nil { t.Fatal(err) } + strictBinding(t, gateway, 1) if _, err := gateway.Open(ctx, 1, 1, raw, headers, Responses); !errors.Is(err, accounts.ErrDisabled) { t.Fatal("disabled conversation silently switched accounts", err) } diff --git a/internal/gateway/groups_test.go b/internal/gateway/groups_test.go index 13ec4b5c..a4e3047e 100644 --- a/internal/gateway/groups_test.go +++ b/internal/gateway/groups_test.go @@ -58,6 +58,7 @@ func TestGroupRoutingAndAffinityNeverEscapeTheirPool(t *testing.T) { t.Fatal(err) } raw := []byte(`{"model":"synthetic-model","input":"synthetic prompt"}`) + strictBinding(t, gateway, a.ID) if _, err := gateway.Open(ctx, 1, a.ID, raw, http.Header{"Session_id": {"same-session"}}, Responses); !errors.Is(err, ErrAffinityUnavailable) { t.Fatal("removed account silently replaced", err) } diff --git a/internal/gateway/review_test.go b/internal/gateway/review_test.go index a1381cf1..978186ca 100644 --- a/internal/gateway/review_test.go +++ b/internal/gateway/review_test.go @@ -155,6 +155,7 @@ func TestReviewKeepsAnExistingChildBinding(t *testing.T) { if _, err := s.accounts.SetEnabled(context.Background(), other, false); err != nil { t.Fatal(err) } + strictBinding(t, s, 1) x, err := s.Open(context.Background(), 1, 1, []byte(`{"model":"codex-auto-review","input":"Synthetic operation"}`), reviewHeaders("synthetic-review"), Responses) if x != nil { x.Body.Close() diff --git a/internal/gateway/selection.go b/internal/gateway/selection.go index 7a100a7b..e8c36066 100644 --- a/internal/gateway/selection.go +++ b/internal/gateway/selection.go @@ -168,6 +168,8 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i // failoverProvider is set when an exhausted binding moves; foreignState is the binding's new fingerprint set. failoverProvider := "" var foreignState []byte + var failoverFrom error + existingAccount := "" if session != "" && derived { // A derived binding is only a preference: unavailable resources fall back to normal scheduling. existing, err := sessionBinding(ctx, q, db.ListSessionAffinitiesParams{GroupID: groupID, UserID: userID, SessionHash: digest[:], Now: now}, scope) @@ -186,9 +188,7 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i if existing != nil { route.Strategy = "affinity" id := existing.AccountID - if !allowed[id] { - return id, digest, ErrAffinityUnavailable - } + existingAccount = id var bound *accounts.Account for i := range available { if available[i].ID == id { @@ -196,20 +196,41 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i break } } - if bound == nil { - return id, digest, accounts.ErrNotFound + boundProvider := preferredProvider(kind) + if bound != nil { + boundProvider = bound.Provider } - if !s.accounts.ProviderEnabled(bound.Provider) { - return id, digest, accounts.ErrProviderDisabled + // failover moves an unusable binding when the group allows it and the request carries no + // provider-held state; otherwise it returns the cause and the conversation keeps its account. + failover := func(cause error) error { + if !failoverCause(cause) || !policy.Routing.QuotaFailover || carrier == nil || !carrier.Movable || existing.Provider != scope { + return cause + } + set, encoded, ok := mergeForeignState(existing.ForeignState, carrier.Present) + if !ok { + return cause + } + carrier.Strip, foreignState, failoverProvider, replacement, failoverFrom = set, encoded, boundProvider, existing, cause + return nil } - if !policy.AllowsProvider(bound.Provider) { + switch { + case !allowed[id]: + if err := failover(ErrAffinityUnavailable); err != nil { + return id, digest, err + } + case bound == nil: + if err := failover(accounts.ErrNotFound); err != nil { + return id, digest, err + } + case !s.accounts.ProviderEnabled(bound.Provider): + return id, digest, accounts.ErrProviderDisabled + case !policy.AllowsProvider(bound.Provider): return id, digest, ErrAffinityUnavailable - } - if kind.codexOnly() && bound.Provider != "codex" { + case kind.codexOnly() && bound.Provider != "codex": return id, digest, upstream.ErrInput } reselect := false - if model != "" && bound.Enabled && bound.Status != "reauth_required" { + if failoverProvider == "" && model != "" && bound.Enabled && bound.Status != "reauth_required" { // A model change alone does not change account identity. After a recorded // account move, older opaque state must still stay off the new account. if affinity == continuationAffinity && existing.Rebound && existing.Model != "" && bindingModel != existing.Model { @@ -231,36 +252,36 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i reselect = true } } - if reselect { - replacement = existing - } else { + admit := func() error { if model != "" && !policy.AllowsResource(bound.Provider, model) { - return id, digest, ErrModelNotAllowed + return ErrModelNotAllowed } if err := s.accountAdmission(*bound); err != nil { - return id, digest, err + return err } if err := s.modelAdmission(ctx, q, id, model); err != nil { - return id, digest, err + return err } if err := quotaAdmission(*bound, model); err != nil { - var quota *QuotaError - // Groups that turn failover off keep the strict rule that conversations never move. - if !errors.As(err, "a) || !policy.Routing.QuotaFailover || carrier == nil || !carrier.Movable || existing.Provider != scope { - return id, digest, err - } - set, encoded, ok := mergeForeignState(existing.ForeignState, carrier.Present) - if !ok { - return id, digest, err - } - carrier.Strip, foreignState, failoverProvider, replacement = set, encoded, bound.Provider, existing + return err } - } - if failoverProvider == "" && !reselect { if scheme != 0 { if _, err := allocations.Check(ctx, q, scheme, userID, groupID, id, model, now, s.location()); err != nil { + return err + } + } + return nil + } + switch { + case reselect: + replacement = existing + case failoverProvider != "": + default: + if err := admit(); err != nil { + if err := failover(err); err != nil { return id, digest, err } + break } if bindingModel == "" { bindingModel = existing.Model @@ -412,6 +433,10 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i } } if len(candidates) == 0 { + // A conversation that found nowhere to move reports why its own account failed. + if failoverFrom != nil { + return existingAccount, digest, failoverFrom + } if allocationErr != nil { return "", digest, allocationErr } diff --git a/internal/storage/db/accounts.sql.go b/internal/storage/db/accounts.sql.go index b1b08862..c9837ef9 100644 --- a/internal/storage/db/accounts.sql.go +++ b/internal/storage/db/accounts.sql.go @@ -93,7 +93,7 @@ func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) (i } const getAccount = `-- name: GetAccount :one -SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision FROM accounts WHERE id = ?1 AND tenant_id = ?2 +SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision, client_platform FROM accounts WHERE id = ?1 AND tenant_id = ?2 ` type GetAccountParams struct { @@ -128,10 +128,31 @@ func (q *Queries) GetAccount(ctx context.Context, arg GetAccountParams) (Account &i.ResetTimeZone, &i.ResetCheckAt, &i.UsageRevision, + &i.ClientPlatform, ) return i, err } +const learnClientPlatform = `-- name: LearnClientPlatform :execrows +UPDATE accounts SET client_platform = ?1 +WHERE id = ?2 AND tenant_id = ?3 AND client_platform IS NULL +` + +type LearnClientPlatformParams struct { + ClientPlatform *string + ID string + TenantID int64 +} + +// First observation wins; a pinned platform never changes with later clients. +func (q *Queries) LearnClientPlatform(ctx context.Context, arg LearnClientPlatformParams) (int64, error) { + result, err := q.db.ExecContext(ctx, learnClientPlatform, arg.ClientPlatform, arg.ID, arg.TenantID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const listAccounts = `-- name: ListAccounts :many SELECT id, provider, name, email, plan, enabled, status, expires_at, created_at, updated_at, max_concurrency, proxy_id, (SELECT count(*) FROM group_accounts ga WHERE ga.account_id=accounts.id) AS group_count diff --git a/internal/storage/db/models.go b/internal/storage/db/models.go index c377c5af..966d3efc 100644 --- a/internal/storage/db/models.go +++ b/internal/storage/db/models.go @@ -28,6 +28,7 @@ type Account struct { ResetTimeZone string ResetCheckAt int64 UsageRevision int64 + ClientPlatform *string } type AccountGroup struct { diff --git a/internal/storage/migrations/037_client_platform.sql b/internal/storage/migrations/037_client_platform.sql new file mode 100644 index 00000000..4abd8b74 --- /dev/null +++ b/internal/storage/migrations/037_client_platform.sql @@ -0,0 +1,3 @@ +-- The client platform an account presents upstream, learned once from a native client and then reused +-- for every request on that account, so one upstream account keeps one client fingerprint. +ALTER TABLE accounts ADD COLUMN client_platform TEXT CHECK(client_platform IS NULL OR length(client_platform) BETWEEN 1 AND 160); diff --git a/internal/storage/queries/accounts.sql b/internal/storage/queries/accounts.sql index 30060f6d..f715c50b 100644 --- a/internal/storage/queries/accounts.sql +++ b/internal/storage/queries/accounts.sql @@ -34,3 +34,8 @@ DELETE FROM accounts WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) -- name: RenameAccount :exec UPDATE accounts SET name = sqlc.arg(name), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: LearnClientPlatform :execrows +-- First observation wins; a pinned platform never changes with later clients. +UPDATE accounts SET client_platform = sqlc.arg(client_platform) +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND client_platform IS NULL; diff --git a/internal/upstream/channel.go b/internal/upstream/channel.go index 64eb4490..91787d07 100644 --- a/internal/upstream/channel.go +++ b/internal/upstream/channel.go @@ -108,7 +108,7 @@ func (c *Client) executionTransport(credential accounts.Credential, headers http if credential.Kind() == "openai" { base = &channelTransport{base: base, credential: credential, session: channelSession(headers, body)} } - return &engineTransport{base: base, codexVersion: c.codexVersion(), grokVersion: c.currentGrokVersion(), provider: credential.Kind()} + return &engineTransport{base: base, codexVersion: c.codexVersion(), codexPlatform: credential.ClientPlatform, grokVersion: c.currentGrokVersion(), provider: credential.Kind()} } // The pinned SDK's compatible executor always generates Chat Completions. diff --git a/internal/upstream/client.go b/internal/upstream/client.go index fc1afca5..c49204a4 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -405,7 +405,7 @@ func upstreamHeaders(request *http.Request, credential accounts.Credential, clie request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+credential.AccessToken) request.Header.Set("Chatgpt-Account-Id", credential.AccountID) - applyCodexVersion(request.Header, version) + applyCodexVersion(request.Header, version, credential.ClientPlatform) request.Header.Set("Accept", "application/json") if stream { request.Header.Set("Accept", "text/event-stream") @@ -418,9 +418,15 @@ func upstreamHeaders(request *http.Request, credential accounts.Credential, clie } } -func applyCodexVersion(headers http.Header, version string) { +// applyCodexVersion presents one client per account: the managed version plus the account's pinned +// platform. Accounts without a learned platform keep the bare version agent. +func applyCodexVersion(headers http.Header, version, platform string) { headers.Set("Originator", "codex_cli_rs") - headers.Set("User-Agent", "codex_cli_rs/"+version) + agent := "codex_cli_rs/" + version + if platform != "" { + agent += " " + platform + } + headers.Set("User-Agent", agent) headers.Set("Version", version) } diff --git a/internal/upstream/engine.go b/internal/upstream/engine.go index 9aeee35b..0bc8ead8 100644 --- a/internal/upstream/engine.go +++ b/internal/upstream/engine.go @@ -85,6 +85,11 @@ func (c *Client) startEngine(parent context.Context, claudeVersions ...string) ( claudeVersion = claudeVersions[0] } cfg.ClaudeHeaderDefaults.UserAgent = claudeUserAgent(claudeVersion) + // Pin each Claude account to one device profile (User-Agent and X-Stainless OS, arch, runtime and + // package versions), as CLIProxyAPI does, so clients on different machines sharing an account + // present a single fingerprint. Profiles are keyed by the stable execution Auth ID. + stabilize := true + cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile = &stabilize // Preserve caller tool definitions; do not inject an image tool into every coding request. if err := json.Unmarshal([]byte(`"passthrough"`), &cfg.DisableImageGeneration); err != nil { return fail(err) @@ -413,6 +418,7 @@ type engineTransport struct { validateGeminiStream bool maxResponseBytes int64 codexVersion string + codexPlatform string grokVersion string base http.RoundTripper mu sync.Mutex @@ -430,14 +436,14 @@ func (t *engineTransport) RoundTrip(r *http.Request) (*http.Response, error) { if t.codexVersion != "" && r.URL.Host == "chatgpt.com" && strings.HasPrefix(r.URL.Path, "/backend-api/codex/") { // Clone before overriding SDK headers; unrelated providers and shared request objects remain untouched. r = r.Clone(r.Context()) - applyCodexVersion(r.Header, t.codexVersion) + applyCodexVersion(r.Header, t.codexVersion, t.codexPlatform) } if t.grokVersion != "" && r.URL.Host == "cli-chat-proxy.grok.com" && strings.HasPrefix(r.URL.Path, "/v1/") { // Apply the durable policy after the pinned SDK's obsolete headers, without mutating its request. r = r.Clone(r.Context()) applyGrokVersion(r.Header, t.grokVersion) } - response, err := t.base.RoundTrip(r) + response, err := t.roundTripConnect(r) if err != nil { t.mu.Lock() t.lastStatus, t.lastRequestID = 0, "" @@ -543,3 +549,39 @@ func (c *Client) Close() { c.http.CloseIdleConnections() c.inference.CloseIdleConnections() } + +// Connection-phase failures (DNS, TCP dial, proxy CONNECT) happen before any request byte reaches the +// provider, so replaying them cannot duplicate work or billing. Anything later is never replayed. +var connectRetryDelays = []time.Duration{250 * time.Millisecond, time.Second} + +func connectFailure(err error) bool { + var op *net.OpError + return errors.As(err, &op) && (op.Op == "dial" || op.Op == "proxyconnect") +} + +func (t *engineTransport) roundTripConnect(r *http.Request) (*http.Response, error) { + for attempt := 0; ; attempt++ { + request := r + if attempt > 0 { + request = r.Clone(r.Context()) + if r.Body != nil && r.Body != http.NoBody { + body, err := r.GetBody() + if err != nil { + return nil, err + } + request.Body = body + } + } + response, err := t.base.RoundTrip(request) + if err == nil || !connectFailure(err) || attempt >= len(connectRetryDelays) || r.Body != nil && r.Body != http.NoBody && r.GetBody == nil { + return response, err + } + timer := time.NewTimer(connectRetryDelays[attempt]) + select { + case <-r.Context().Done(): + timer.Stop() + return nil, err + case <-timer.C: + } + } +} diff --git a/internal/upstream/fingerprint_test.go b/internal/upstream/fingerprint_test.go new file mode 100644 index 00000000..77310a82 --- /dev/null +++ b/internal/upstream/fingerprint_test.go @@ -0,0 +1,67 @@ +package upstream + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "syscall" + "testing" + "time" +) + +func TestCodexAgentPresentsAccountPlatform(t *testing.T) { + headers := http.Header{} + applyCodexVersion(headers, "0.155.1", "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1") + if headers.Get("User-Agent") != "codex_cli_rs/0.155.1 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1" || headers.Get("Version") != "0.155.1" { + t.Fatal("account platform missing", headers) + } + applyCodexVersion(headers, "0.155.1", "") + if headers.Get("User-Agent") != "codex_cli_rs/0.155.1" { + t.Fatal("unlearned account changed agent", headers.Get("User-Agent")) + } +} + +func TestConnectFailuresRetryOnlyBeforeRequestBytes(t *testing.T) { + restore := connectRetryDelays + connectRetryDelays = []time.Duration{time.Millisecond, time.Millisecond} + defer func() { connectRetryDelays = restore }() + for _, test := range []struct { + name string + failure error + calls int + ok bool + }{ + {"dial", &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}, 2, true}, + {"proxy connect", &net.OpError{Op: "proxyconnect", Err: syscall.ECONNREFUSED}, 2, true}, + {"reset after send", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, 1, false}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + var bodies []string + transport := &engineTransport{base: usageTransport(func(r *http.Request) (*http.Response, error) { + calls++ + raw, _ := io.ReadAll(r.Body) + bodies = append(bodies, string(raw)) + if calls == 1 { + return nil, test.failure + } + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(nil)), Request: r}, nil + })} + request, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://synthetic.invalid/v1", bytes.NewReader([]byte("synthetic-body"))) + response, err := transport.RoundTrip(request) + if response != nil { + response.Body.Close() + } + if calls != test.calls || (err == nil) != test.ok { + t.Fatalf("calls=%d err=%v", calls, err) + } + for _, body := range bodies { + if body != "synthetic-body" { + t.Fatal("replayed request lost its body", bodies) + } + } + }) + } +} diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index af729a1e..a6f087ab 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1563,9 +1563,9 @@ "sessionSticky": "تثبيت الجلسة", "sessionStickyHint": "يُبقي المحادثات التي لا تحمل معرّف جلسة على مورد واحد باستخدام تجزئة الطلب الافتتاحي، ويجعل الوكلاء الفرعيين يفضلون مورد الوكيل الأصلي. تستمر الموارد غير المتاحة في التحويل التلقائي. يحسّن إصابات ذاكرة التخزين المؤقت للطلبات.", "sessionStickyEnabled": "تثبيت الجلسة مفعّل", - "quotaFailover": "تبديل الحساب عند نفاد الحصة", - "quotaFailoverHint": "يسمح بنقل محادثة قائمة إلى اشتراك آخر لدى المزوّد نفسه عند نفاد حصة حسابها بدلاً من الفشل. لا يُرسل الاستدلال المشفّر من الحساب السابق، ويُحتفظ بسجل المحادثة المرئي. لا تنتقل المحادثات التي تستخدم حالة مخزّنة على الخادم.", - "quotaFailoverEnabled": "التبديل عند نفاد الحصة مفعّل", + "quotaFailover": "تبديل الحساب عندما يصبح غير متاح", + "quotaFailoverHint": "إذا نفدت حصة حساب المحادثة، أو قُيّد لخمس دقائق أو أكثر، أو عُطّل، أو احتاج إلى إعادة تفويض، أو أُزيل من المجموعة، تستمر المحادثة على حساب آخر لدى المزوّد نفسه بدلاً من الفشل. لا يُرسل الاستدلال المشفّر من الحساب السابق، ويُحتفظ بسجل المحادثة المرئي. لا تنتقل المحادثات التي تستخدم حالة مخزّنة على الخادم.", + "quotaFailoverEnabled": "التبديل التلقائي للحساب مفعّل", "editChannel": "تعديل القناة", "channelEditDescription": "أعد تسمية القناة أو استبدل مفتاحها. لاستخدام نقطة نهاية أو بروتوكول آخر، أضف قناة جديدة حتى لا يصل المفتاح المحفوظ إلى مزوّد آخر.", "channelKeyKeepHint": "اتركه فارغًا للاحتفاظ بالمفتاح المحفوظ. لا يتم عرض المفتاح المحفوظ أبدًا.", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 4577531c..8b36e50c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1543,9 +1543,9 @@ "sessionSticky": "Sitzungsbindung", "sessionStickyHint": "Hält Unterhaltungen ohne Sitzungs-ID über einen Hash des ersten Prompts auf einer Ressource und lässt Subagenten die Ressource ihres Elternteils bevorzugen. Nicht verfügbare Ressourcen werden weiterhin umgangen. Verbessert Prompt-Cache-Treffer beim Anbieter.", "sessionStickyEnabled": "Sitzungsbindung aktiv", - "quotaFailover": "Konto wechseln, wenn das Kontingent erschöpft ist", - "quotaFailoverHint": "Lässt eine bestehende Unterhaltung zu einem anderen Abonnement desselben Anbieters wechseln, wenn das Kontingent ihres Kontos erschöpft ist, statt fehlzuschlagen. Verschlüsseltes Reasoning des vorherigen Kontos wird nicht weitergegeben; das sichtbare Transkript bleibt erhalten. Unterhaltungen mit serverseitig gespeichertem Zustand wechseln nie.", - "quotaFailoverEnabled": "Kontingent-Wechsel aktiv", + "quotaFailover": "Konto wechseln, wenn es nicht verfügbar ist", + "quotaFailoverHint": "Wenn das Konto einer Unterhaltung sein Kontingent erschöpft, fünf Minuten oder länger gedrosselt wird, deaktiviert wird, eine erneute Autorisierung braucht oder die Gruppe verlässt, wird die Unterhaltung auf einem anderen Konto desselben Anbieters fortgesetzt, statt fehlzuschlagen. Verschlüsseltes Reasoning des vorherigen Kontos wird nicht weitergegeben; das sichtbare Transkript bleibt erhalten. Unterhaltungen mit serverseitig gespeichertem Zustand wechseln nie.", + "quotaFailoverEnabled": "Kontowechsel aktiv", "editChannel": "Kanal bearbeiten", "channelEditDescription": "Benennen Sie den Kanal um oder ersetzen Sie seinen Schlüssel. Für einen anderen Endpunkt oder ein anderes Protokoll fügen Sie einen neuen Kanal hinzu, damit der gespeicherte Schlüssel nie einen anderen Anbieter erreicht.", "channelKeyKeepHint": "Leer lassen, um den gespeicherten Schlüssel zu behalten. Der gespeicherte Schlüssel wird nie angezeigt.", diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 0f44b2fa..f3ced7d5 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1932,10 +1932,10 @@ export const en = { sessionStickyHint: 'Keeps conversations without a session ID on one resource, using a hash of the opening prompt, and lets subagents prefer their parent’s resource. Unavailable resources still fail over. Improves upstream prompt cache hits.', sessionStickyEnabled: 'Session stickiness on', - quotaFailover: 'Switch accounts when quota runs out', + quotaFailover: 'Switch accounts when one becomes unavailable', quotaFailoverHint: - 'Lets an existing conversation move to another subscription of the same provider when its account’s quota is exhausted, instead of failing. Encrypted reasoning from the previous account is withheld; the visible transcript is kept. Conversations using server-stored state never move.', - quotaFailoverEnabled: 'Quota failover on', + 'When a conversation’s account runs out of quota, is rate-limited for five minutes or more, is disabled, needs reauthorization or leaves the group, the conversation continues on another account of the same provider instead of failing. Encrypted reasoning from the previous account is withheld; the visible transcript is kept. Conversations using server-stored state never move.', + quotaFailoverEnabled: 'Account failover on', editChannel: 'Edit channel', channelKeyKeepHint: 'Leave blank to keep the stored key. The stored key is never shown.', diff --git a/web/src/locales/es.json b/web/src/locales/es.json index a643d6d7..eecfe40c 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1548,9 +1548,9 @@ "sessionSticky": "Persistencia de sesión", "sessionStickyHint": "Mantiene las conversaciones sin ID de sesión en un mismo recurso mediante un hash del mensaje inicial y hace que los subagentes prefieran el recurso de su padre. Los recursos no disponibles siguen conmutando. Mejora los aciertos de caché de prompts.", "sessionStickyEnabled": "Persistencia de sesión activada", - "quotaFailover": "Cambiar de cuenta al agotar la cuota", - "quotaFailoverHint": "Permite que una conversación existente pase a otra suscripción del mismo proveedor cuando se agota la cuota de su cuenta, en lugar de fallar. El razonamiento cifrado de la cuenta anterior no se envía; se conserva la transcripción visible. Las conversaciones con estado almacenado en el servidor nunca se mueven.", - "quotaFailoverEnabled": "Cambio por cuota activado", + "quotaFailover": "Cambiar de cuenta cuando una deja de estar disponible", + "quotaFailoverHint": "Si la cuenta de una conversación agota su cuota, queda limitada cinco minutos o más, se desactiva, requiere reautorización o sale del grupo, la conversación continúa en otra cuenta del mismo proveedor en lugar de fallar. El razonamiento cifrado de la cuenta anterior no se envía; se conserva la transcripción visible. Las conversaciones con estado almacenado en el servidor nunca se mueven.", + "quotaFailoverEnabled": "Cambio de cuenta activado", "editChannel": "Editar canal", "channelEditDescription": "Cambia el nombre del canal o sustituye su clave. Para usar otro endpoint o protocolo, añade un canal nuevo para que la clave guardada nunca llegue a otro proveedor.", "channelKeyKeepHint": "Déjalo en blanco para conservar la clave guardada. La clave guardada nunca se muestra.", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 43517512..11b812f0 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1548,9 +1548,9 @@ "sessionSticky": "Affinité de session", "sessionStickyHint": "Conserve les conversations sans identifiant de session sur une même ressource grâce à un hachage du prompt initial, et fait préférer aux sous-agents la ressource de leur parent. Les ressources indisponibles basculent toujours. Améliore les succès du cache de prompts.", "sessionStickyEnabled": "Affinité de session activée", - "quotaFailover": "Changer de compte quand le quota est épuisé", - "quotaFailoverHint": "Permet à une conversation existante de passer à un autre abonnement du même fournisseur lorsque le quota de son compte est épuisé, au lieu d’échouer. Le raisonnement chiffré de l’ancien compte n’est pas transmis ; la transcription visible est conservée. Les conversations utilisant un état stocké côté serveur ne changent jamais de compte.", - "quotaFailoverEnabled": "Bascule sur quota activée", + "quotaFailover": "Changer de compte quand il devient indisponible", + "quotaFailoverHint": "Si le compte d’une conversation épuise son quota, est limité pendant cinq minutes ou plus, est désactivé, doit être réautorisé ou quitte le groupe, la conversation continue sur un autre compte du même fournisseur au lieu d’échouer. Le raisonnement chiffré de l’ancien compte n’est pas transmis ; la transcription visible est conservée. Les conversations utilisant un état stocké côté serveur ne changent jamais de compte.", + "quotaFailoverEnabled": "Bascule de compte activée", "editChannel": "Modifier le canal", "channelEditDescription": "Renommez le canal ou remplacez sa clé. Pour utiliser un autre point de terminaison ou protocole, ajoutez un nouveau canal afin que la clé enregistrée n’atteigne jamais un autre fournisseur.", "channelKeyKeepHint": "Laissez vide pour conserver la clé enregistrée. La clé enregistrée n’est jamais affichée.", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 16460dc7..6b5bf15c 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -1543,9 +1543,9 @@ "sessionSticky": "セッションの固定", "sessionStickyHint": "セッション ID のない会話を冒頭プロンプトのハッシュで同じリソースに固定し、サブエージェントには親のリソースを優先させます。利用できないリソースは引き続きフェイルオーバーします。上流のプロンプトキャッシュのヒット率が向上します。", "sessionStickyEnabled": "セッション固定オン", - "quotaFailover": "クォータ切れでアカウントを切り替える", - "quotaFailoverHint": "既存の会話のアカウントでクォータが尽きた場合、エラーにせず同じプロバイダーの別のサブスクリプションへ移します。元アカウントの暗号化された推論は送信せず、表示されている会話履歴は保持します。サーバー保存の状態を使う会話は移動しません。", - "quotaFailoverEnabled": "クォータ切れ時の切り替えオン", + "quotaFailover": "アカウントが使えないときに切り替える", + "quotaFailoverHint": "会話のアカウントでクォータが尽きた、5 分以上レート制限された、無効化された、再認証が必要になった、またはグループから外れた場合、エラーにせず同じプロバイダーの別アカウントで続行します。元アカウントの暗号化された推論は送信せず、表示されている会話履歴は保持します。サーバー保存の状態を使う会話は移動しません。", + "quotaFailoverEnabled": "アカウント自動切り替えオン", "editChannel": "チャンネルを編集", "channelEditDescription": "チャンネル名の変更やキーの差し替えができます。別のエンドポイントやプロトコルを使う場合は、保存済みのキーが別の上流に送られないよう新しいチャンネルを追加してください。", "channelKeyKeepHint": "空欄のままにすると保存済みのキーを維持します。保存済みのキーは表示されません。", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 23962bf1..9a2f7675 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1543,9 +1543,9 @@ "sessionSticky": "세션 고정", "sessionStickyHint": "세션 ID가 없는 대화를 시작 프롬프트의 해시로 같은 리소스에 고정하고, 하위 에이전트가 상위 리소스를 우선 사용하게 합니다. 사용할 수 없는 리소스는 계속 장애 조치됩니다. 업스트림 프롬프트 캐시 적중률이 높아집니다.", "sessionStickyEnabled": "세션 고정 켜짐", - "quotaFailover": "할당량 소진 시 계정 전환", - "quotaFailoverHint": "기존 대화의 계정 할당량이 소진되면 실패하는 대신 같은 공급자의 다른 구독으로 옮깁니다. 이전 계정의 암호화된 추론은 보내지 않으며, 보이는 대화 기록은 유지됩니다. 서버에 저장된 상태를 쓰는 대화는 옮기지 않습니다.", - "quotaFailoverEnabled": "할당량 소진 전환 켜짐", + "quotaFailover": "계정을 사용할 수 없으면 전환", + "quotaFailoverHint": "대화의 계정 할당량이 소진되거나, 5분 이상 속도 제한되거나, 비활성화되거나, 재인증이 필요하거나, 그룹에서 제외되면 실패하는 대신 같은 공급자의 다른 계정으로 계속합니다. 이전 계정의 암호화된 추론은 보내지 않으며, 보이는 대화 기록은 유지됩니다. 서버에 저장된 상태를 쓰는 대화는 옮기지 않습니다.", + "quotaFailoverEnabled": "계정 자동 전환 켜짐", "editChannel": "채널 편집", "channelEditDescription": "채널 이름을 바꾸거나 키를 교체합니다. 다른 엔드포인트나 프로토콜을 사용하려면 저장된 키가 다른 업스트림으로 전송되지 않도록 새 채널을 추가하세요.", "channelKeyKeepHint": "비워 두면 저장된 키가 유지됩니다. 저장된 키는 표시되지 않습니다.", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 4ec40e5b..86fea082 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1548,9 +1548,9 @@ "sessionSticky": "Persistência de sessão", "sessionStickyHint": "Mantém conversas sem ID de sessão em um mesmo recurso usando um hash do prompt inicial e faz os subagentes preferirem o recurso do pai. Recursos indisponíveis continuam com failover. Melhora os acertos de cache de prompt.", "sessionStickyEnabled": "Persistência de sessão ativada", - "quotaFailover": "Trocar de conta quando a cota acabar", - "quotaFailoverHint": "Permite que uma conversa existente passe para outra assinatura do mesmo provedor quando a cota da conta se esgota, em vez de falhar. O raciocínio criptografado da conta anterior não é enviado; a transcrição visível é mantida. Conversas com estado armazenado no servidor nunca mudam de conta.", - "quotaFailoverEnabled": "Troca por cota ativada", + "quotaFailover": "Trocar de conta quando ficar indisponível", + "quotaFailoverHint": "Se a conta de uma conversa esgotar a cota, for limitada por cinco minutos ou mais, for desativada, precisar de nova autorização ou sair do grupo, a conversa continua em outra conta do mesmo provedor em vez de falhar. O raciocínio criptografado da conta anterior não é enviado; a transcrição visível é mantida. Conversas com estado armazenado no servidor nunca mudam de conta.", + "quotaFailoverEnabled": "Troca de conta ativada", "editChannel": "Editar canal", "channelEditDescription": "Renomeie o canal ou substitua sua chave. Para usar outro endpoint ou protocolo, adicione um novo canal para que a chave salva nunca chegue a outro provedor.", "channelKeyKeepHint": "Deixe em branco para manter a chave salva. A chave salva nunca é exibida.", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 304f182e..681fd331 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1553,9 +1553,9 @@ "sessionSticky": "Привязка сессий", "sessionStickyHint": "Закрепляет диалоги без идентификатора сессии за одним ресурсом по хешу первого запроса и позволяет субагентам предпочитать ресурс родителя. Недоступные ресурсы по-прежнему переключаются. Повышает попадания в кэш промптов.", "sessionStickyEnabled": "Привязка сессий включена", - "quotaFailover": "Переключать аккаунт при исчерпании квоты", - "quotaFailoverHint": "Позволяет существующему диалогу перейти на другую подписку того же провайдера, когда квота его аккаунта исчерпана, вместо ошибки. Зашифрованные рассуждения прежнего аккаунта не передаются; видимая история сохраняется. Диалоги с состоянием, хранящимся на сервере, не переключаются.", - "quotaFailoverEnabled": "Переключение по квоте включено", + "quotaFailover": "Переключать аккаунт, если он недоступен", + "quotaFailoverHint": "Если у аккаунта диалога исчерпана квота, он ограничен на пять минут или дольше, отключён, требует повторной авторизации или удалён из группы, диалог продолжается на другом аккаунте того же провайдера вместо ошибки. Зашифрованные рассуждения прежнего аккаунта не передаются; видимая история сохраняется. Диалоги с состоянием, хранящимся на сервере, не переключаются.", + "quotaFailoverEnabled": "Переключение аккаунтов включено", "editChannel": "Изменить канал", "channelEditDescription": "Переименуйте канал или замените ключ. Для другого адреса или протокола добавьте новый канал, чтобы сохранённый ключ не попал к другому провайдеру.", "channelKeyKeepHint": "Оставьте пустым, чтобы сохранить текущий ключ. Сохранённый ключ никогда не отображается.", diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index bcf075ef..2ad33861 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -1543,9 +1543,9 @@ "sessionSticky": "工作階段黏性", "sessionStickyHint": "對未攜帶工作階段 ID 的對話,依開頭提示詞的雜湊固定到同一資源,並讓子代理優先使用父工作階段的資源。資源不可用時仍會自動切換。可提高上游提示詞快取命中率。", "sessionStickyEnabled": "已開啟工作階段黏性", - "quotaFailover": "額度耗盡時允許工作階段切換帳號", - "quotaFailoverHint": "既有工作階段所在帳號額度耗盡時,改為切換到同一服務商的其他訂閱繼續,而不是直接報錯。原帳號的加密推理內容不會傳送給新帳號,可見的對話紀錄會保留。使用伺服器端儲存狀態的工作階段不會切換。", - "quotaFailoverEnabled": "已開啟額度耗盡切換", + "quotaFailover": "帳號不可用時自動切換帳號", + "quotaFailoverHint": "工作階段所在帳號額度耗盡、被限流 5 分鐘以上、被停用、需要重新授權或被移出資源組時,自動切換到同一服務商的其他帳號繼續,而不是直接報錯。原帳號的加密推理內容不會傳送給新帳號,可見的對話紀錄會保留。使用伺服器端儲存狀態的工作階段不會切換。", + "quotaFailoverEnabled": "已開啟帳號自動切換", "editChannel": "編輯渠道", "channelEditDescription": "修改渠道名稱或更換金鑰。如需使用其他端點或協定,請新建渠道,避免已儲存的金鑰被傳送到其他上游。", "channelKeyKeepHint": "留空則保留已儲存的金鑰。已儲存的金鑰不會顯示。", diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 455ebde5..b302dc9d 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1765,10 +1765,10 @@ export const zh: Record = { sessionStickyHint: '对未携带会话 ID 的对话,按开头提示词的哈希固定到同一资源,并让子代理优先使用父会话的资源。资源不可用时仍会自动切换。可提高上游提示词缓存命中率。', sessionStickyEnabled: '已开启会话粘性', - quotaFailover: '额度耗尽时允许会话切换账号', + quotaFailover: '账号不可用时自动切换账号', quotaFailoverHint: - '已有会话所在账号额度耗尽时,改为切换到同一服务商的其他订阅继续,而不是直接报错。原账号的加密推理内容不会发送给新账号,可见的对话记录会保留。使用服务端存储状态的会话不会切换。', - quotaFailoverEnabled: '已开启额度耗尽切换', + '会话所在账号额度耗尽、被限流 5 分钟以上、被停用、需要重新授权或被移出资源组时,自动切换到同一服务商的其他账号继续,而不是直接报错。原账号的加密推理内容不会发送给新账号,可见的对话记录会保留。使用服务端存储状态的会话不会切换。', + quotaFailoverEnabled: '已开启账号自动切换', editChannel: '编辑渠道', channelKeyKeepHint: '留空则保留已保存的密钥。已保存的密钥不会显示。', saveChannel: '保存渠道',