Skip to content

feat(proxy): seat_group resolve/select + persona + group runtime#1

Open
Jakegogo wants to merge 63 commits into
develop-1.0.3from
feature/seat-group
Open

feat(proxy): seat_group resolve/select + persona + group runtime#1
Jakegogo wants to merge 63 commits into
develop-1.0.3from
feature/seat-group

Conversation

@Jakegogo

Copy link
Copy Markdown
Contributor

Part of the seat_group (席位组 / OAuth account pool) feature.

Group VK resolution, seatassign selection, AccountPersona normalization (pooled), group_runtime pull. Direct-bind path byte-unchanged.

🤖 Generated with Claude Code

…runtime pull

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@raysonmeng

Copy link
Copy Markdown

@Jakegogo 「override 谁写谁读」(动态决策引擎冲突 5)。分工:引擎当 assignment_override 的写者(引擎内,我们做);override 的下发端点 + proxy 读取这两块管道归你建。给足 spec:

背景

引擎(master 调度大脑)会按风险/容量决定"某席位下次该走哪个号",写进 seat_group_member.assignment_override(你已建好这列)。但你 proxy 现状:① 路由纯跑 seatassign.Rank 取第一个可用,完全不读 assignment_override;② 连 master 的 GroupAccountRef.assigned(rank-0 advisory)也显式忽略、自己重排。→ 引擎写了 override 没人读、指挥不动流量。assigned 你忽略没问题(它就是哈希第一名),但 assignment_override 是引擎的动态风险决定、不是哈希,proxy 必须读。

要你建的两块(引擎只当写者,不碰你的 seatassign)

  1. GET /accounts/me/routing 下发端点(master/control-master)——你现在只有 group-runtime(下发加密物料),没有 override 下发。新建这个端点返回 seat → account_id 映射(= 引擎写的 override)+ routing_version 增量语义。与 group-runtime 分开走(不复用 60s 物料拉取通道;这是 §5.4b 的 routing 快路径、不 bump sync_version)。cli 里那个注释提到的 "routing-override POLL(N12)" 就是它,目前零代码。
  2. proxy group_resolve.go 加"override 优先"读取——选号前先看这个席位有没有有效 override:override.account_id ∈ 本地 group_accounts 候选集(成员有效性校验,候选集走结构同步、override 走快路径,两路径异步无版本绑定,必须双侧校验)。有效就用它当起点;否则回退现有 seatassign.Rank(现状行为一行不动)

安全网(贴合你现状)

没 override 时(引擎没上线/没写)→ proxy 就是回退纯 seatassign = 你现在的行为。所以这是优雅叠加:"没 override = 你现状,有 override = 引擎指挥",引擎挂了系统照跑(不变量 1)。

一句话:引擎写 override 值(引擎内);你建 routing 端点 + proxy 加 override 优先读(带成员有效性校验)。assigned 忽略不用改。

🤖 Generated with Claude Code

@raysonmeng

Copy link
Copy Markdown

@Jakegogo 引擎以外待建 · 信号底座(I5,承接前面 override 那条)。好消息:上报通道不用新建,搭现成用量管道即可。

proxy 现在对 unified-* 只透传 window_max_util_pct + 看 window_status==exhausted 布尔,把利用率数值丢了。引擎风险评分要"撞墙前预警"(利用率斜率 d(util)/dt、消耗速率),光 exhausted 布尔算不出斜率。要两步:

  1. 真解析 unified- 数值*:anthropic-ratelimit-unified-5h/7d-utilization(float 0~1)、-{5h,7d,opus-7d}-reset-overage-*、401 OAuth token has been revoked(不是只取 exhausted 布尔)。forward_and_resolve.go:475 你已经把这些头抓在 rateLimitHeaders 里打日志了,差的就是解析成数值。
  2. 不另建上报通道——搭现成:把解析出的数值塞进 proxy 已有的用量事件(events_and_helpers canonical event),顺 aikey-data 用量管道进 ODS。上报频率 = 用量事件频率(每请求、零额外成本);引擎从 ODS 按自己再平衡节拍(1~5min)取、算斜率,你不用定上报节奏

即:你采+解析+搭车上报,引擎在 ODS 上消费。落点和 persona 归一同仓。

🤖 Generated with Claude Code

@raysonmeng

Copy link
Copy Markdown

@Jakegogo 更正一下上面那条 override 评论的口径 🙏 —— 我后来看了你 760ea08「clarify candidate-set vs single routing account」的澄清,发现这正是你自己 §5.4b 的设计,不是我们要新加的东西。所以上面那条 spec 的本质是「请把你 §5.4b 已经设计好的东西在这个 PR 里落地」:

你 §5.4b / 760ea08 已经定义好的(我们完全照用,不改设计):

  • 端点 GET /accounts/me/routing?since=routing_version(返回 seat→当前账号映射,变了才重拉)
  • proxy 读法「有 override 就覆盖本地 hash、没有就用本地 hash」
  • 字段 my_assignment_override(cache 投影 {protocol:account_id})/ seat_group_member.assignment_override(master 权威)
  • 写者 = 你标注的「远端路由轮询(N12)」= 就是引擎
  • 候选集是权威集合,override 只是其上的一个选择,候选集收缩使存量 override 失效 —— 这条成员有效性校验我们双侧都做(master 写前校验 account_id ∈ group_accounts、proxy 应用前再校验,不在则丢 override 退回 hash)

现状:你这套 §5.4b 设计已定,但 PR 代码里还是零(端点、proxy override 读都没接,proxy 现在纯 seatassign 顺序取可用、忽略 advisory)。所以引擎按你这套契约当 override 唯一写者,你把 §5.4b 落地即可,无需改任何设计

(前一条评论说「你留空了、按我们 spec 建」口径不准,以这条为准——是落地你自己的 §5.4b。)

🤖 Generated with Claude Code

@Jakegogo

Copy link
Copy Markdown
Contributor Author

@raysonmeng N12 override 分工收到(引擎写 assignment_override、我建下发端点 + proxy 读)。先把接口契约敲死,免得两边并行写、接的时候对不上。下面是我的提案,请确认/调整:

1. 下发端点 GET /accounts/me/routing

  • 鉴权:account-JWT(同 group-runtime)。
  • 返回(席位→账号,稀疏,只列有 override 的席位;无则空):
    { "routing_version": 7, "assignments": { "<seat_id>": "<account_id>", ... } }
  • routing_version:单调递增整数,任一 assignment 变更即 +1。proxy 记住上次版本,版本未变 → 整个跳过(不重处理)。
  • 独立通道:与 group-runtime 物料管道分开不 bump sync_version(§5.4b 快路径)。
  • 空语义:引擎未写/未上线 → 返回 assignments: {} → proxy 全量回退到现有 seatassign(= 现状)。

2. proxy 侧(我建)

  • 轮询:独立 60s ticker 拉该端点(复用 RefreshableJWT),override 表只存内存(非密、易变 → 不写 vault、不 reload);版本未变不动。
  • override 优先读group_resolve.go,选号前):
    • 该 seat 有 override override.account_id ∈ 本地候选集 该账号当前可用 → 用它当选号;
    • 否则 → 现有 seatassign.Rank现状一行不动)。

3. 两个待你确认的点

  • (a) override = 偏好,不是硬命令:若引擎指定的账号正好被 proxy 冷却(刚 401/exhaustion-429)或过期/耗尽 → proxy 仍绕开它、回退选号(账号已挂是硬事实,不该把请求往死号上送)。同意吗?
  • (b) 端点谁建GET /accounts/me/routing 在 master、紧挨你写 assignment_override 的地方——建议你顺手建(读 seat_group_member.assignment_override → 返上面格式);我这边建轮询 + override 优先读。可否?

4. 安全网 / 边界

  • 无 override / 引擎挂 → proxy = 现状(优雅叠加,不变量 1);全程在 AIKEY_PROXY_SEAT_GROUP_ENABLED 开关后。
  • 校验"override 账号 ∈ 本地候选集"——候选集走结构同步、override 走快路径,两路异步必须双侧校验。
  • N12 整体仍属 post-MVP;既然你在写引擎,我把 proxy 这半边按上面契约建好对接。

契约确认后我就开写 proxy 轮询 + override 优先读。

🤖 Generated with Claude Code

@raysonmeng

Copy link
Copy Markdown

@Jakegogo routing 契约逐条确认,可以开写:

1. 下发端点 GET /accounts/me/routing —— 我们建(master 侧)

端点紧挨引擎写 assignment_override 处,由引擎侧建:读 seat_group_member.assignment_override → 返你定义的格式 { "routing_version": N, "assignments": { "<seat_id>": "<account_id>" } }

  • routing_version 由引擎写 override 时单调 +1(每次 BuildOverrideMap 递增);
  • 稀疏,只列有 override 的席位,无则 assignments: {}
  • account-JWT 鉴权、独立通道、不 bump sync_version(§5.4b 快路径)。

全照你的提案,零改动。

2. proxy 侧(你建)—— 全采纳

60s 独立 ticker、override 表只存内存(非密不写 vault)、版本未变整跳过、override 优先读 + 候选集校验、否则回退 seatassign.Rank(现状一行不动)。

3. 两点确认

  • (a) override = 偏好非硬命令:同意。 引擎指定的号若正被 proxy 冷却(刚 401/429)/过期/耗尽 → proxy 仍绕开、回退选号。这正是不变量 1(引擎挂了系统照跑)+「死号是硬事实,不该往上送」。引擎只给偏好,proxy 的熔断硬闸门永远优先。
  • (b) 端点谁建:我们建 master 端点,你建 proxy 轮询 + override 优先读。 §5.4b 整套是你的设计,我们只把 master 这半边落地(读 override 列发出去);proxy 半边你按上面契约接。

4. 安全网 —— 一致

无 override / 引擎挂 → proxy = 现状;全程在 AIKEY_PROXY_SEAT_GROUP_ENABLED 后;候选集双侧校验(master 写前 + proxy 应用前,account_id ∈ group_accounts,不在则丢 override 退 hash)。N12 整体仍 post-MVP。

契约敲定。master 端点跟 allocengine override 写入同 PR 出;你出 proxy 轮询 + override 优先读。

Jakegogo and others added 24 commits June 26, 2026 11:14
[aikey-proxy] group-VK resolve/select + OAuth-pool routing with per-account
window cap, cooldown and reset, persona normalization, group-runtime pull; fix
group-VK 502 (resolve provider per selected account instead of the empty
VK-level ProviderCode); drop the proxy-local impersonate client for broker's.

Refs:
- group-VK empty provider-code 502 bugfix: https://github.com/aikeylabs/workflow/blob/3eb36d250c0f02ed28ba4753b6fa468d1fcf1b59/CI/bugfix/2026-06-25-group-vk-empty-provider-code-502.md
- seat-group / OAuth account pool requirement spec: https://github.com/aikeylabs/workflow/blob/3eb36d250c0f02ed28ba4753b6fa468d1fcf1b59/CI/requirements/2026-06-23-oauth-account-pool.md
The proxy half of I5 (parse upstream unified-* utilization → ship to master), on a
NEW branch off jake's feature/seat-group (his branch untouched). Self-contained,
main-link-safe by construction:
- signalReporter: non-blocking enqueue (full buffer drops the sample — trend
  signal, never block forwarding), background batch upload, all errors logged not
  surfaced; nil reporter = feature off.
- parseUnifiedUtil5h: clean-parse-only off the response header.
- POSTs to ControlURL + /accounts/me/signals (the master receive endpoint built
  control-side, commit 99f033e).

NOT yet wired (two remaining): (a) the hook in buildBaseEvent (parse + enqueue,
trivial, nil-safe), (b) the AUTH bearer — /accounts/me/signals needs a master
account-JWT, but the proxy's existing reporter credentials are collector-audience
(CollectorToken / route creds), not a master account-JWT; grabbing the right
account JWT is jake's seat_group account-auth wiring. That's the last integration
point. route.CredentialID (types.go:72) supplies the sample key at the hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the proxy half of I5 on the spike branch (jake's feature/seat-group
untouched):
- Proxy gains a signalReporter field + EnableSignalReporting(controlURL, bearer).
- buildBaseEvent hook (post-response): parseUnifiedUtil5h(resp.Header) → nil-safe,
  non-blocking signalReporter.enqueue(route.CredentialID, now, util). Off the hot
  path — a full buffer drops the sample, never stalls forwarding.
- supervisor wires it where it sets the usage reporter, reusing the team
  account-JWT (buildCollectorCredentials(...)["team"].Bearer — the SAME credential
  the group-runtime poll authenticates with) → master /accounts/me/signals +
  readControlPanelURL(). Off unless a team credential + control URL exist.

I5 now end-to-end: proxy parse → async POST → master receive (commit 99f033e) →
engine_meta ring → engine SignalSource → DeriveSignals. Full proxy `go build ./...`
green, vet clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… group-VK fixes

[aikey-proxy] path-prefix route dispatches group VKs to the group handler (was
403 on empty provider — only the probe entry was affected); seat-group routing
flag now defaults ON (AIKEY_PROXY_SEAT_GROUP_ENABLED=0 to disable); pass empty
egress proxy (proxy node has its own egress, R25).

Refs:
- group-VK connectivity-test 503 bugfix: https://github.com/aikeylabs/workflow/blob/9dbdc448e094f7719d9cceb2e4138fe68e015276/CI/bugfix/2026-06-26-vault-test-group-vk-and-error-code.md
- seat-group / OAuth pool requirement spec (R25): https://github.com/aikeylabs/workflow/blob/9dbdc448e094f7719d9cceb2e4138fe68e015276/CI/requirements/2026-06-23-oauth-account-pool.md
Table test for parseUnifiedUtil5h, nil-safe/non-blocking enqueue, and post()
against an httptest server asserting POST + Bearer + JSON body. Covers the
fault-isolated emit path that ships unified-5h-utilization to master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When an upstream 401 indicates the credential's OAuth token was hard-revoked
(isHardRevoked: status 401 + 'revoked' substring in the already-parsed error
type/message), the proxy best-effort emits {credential_id,reason} to master's
/accounts/me/signals revoked[] array, riding the existing I5 30s flush (no POST
storm). Detection lives in recordEvent (the universal upstream-error path) reusing
captureUpstreamErrorBody's parse — zero extra body reads, the 401 is never altered.
Self-test probes are excluded so they can't trigger a quarantine. Narrow keyword
match avoids false-positive quarantines on routine token-expiry 401s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… loopback probe hijack

[aikey-proxy] strip the provider prefix before handing a group VK to the group
handler — it forwards r.URL.Path verbatim, so an unstripped /anthropic/v1/...
hit api.anthropic.com/anthropic/... → 404 (regression from the earlier
path-prefix group-dispatch 403 fix).

Refs:
- group-VK path-strip 404 root-cause chain (§78+): https://github.com/aikeylabs/workflow/blob/6306efcd67b597c44e3c87a0e16da61bd6bd9514/CI/bugfix/2026-06-26-vault-test-group-vk-and-error-code.md
- seat-group / OAuth pool requirement spec: https://github.com/aikeylabs/workflow/blob/6306efcd67b597c44e3c87a0e16da61bd6bd9514/CI/requirements/2026-06-23-oauth-account-pool.md
…eed)

Feeds the engine's §5.1 w5 recent-rate-limit signal. recordEvent() increments a
per-credential 429/403 counter (mutex-guarded map, snapshot+cleared each 30s flush —
a counter not per-event POST, since 429s burst). The flush adds an optional
rate_limits[] array {credential_id,count,window_secs} alongside samples/revoked.
Self-test probes excluded; response untouched; never blocks the forward path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
util_7d: parseUnifiedUtil7d mirrors the 5h parser; the util sample now carries
util_7d (omitempty, byte-identical when absent) for the engine's weekly squeeze.
ConcurrencyPeak: track in-flight forwarded requests per credential (defer inc/dec at
the serveRoute chokepoint, leak-proof), report the window peak in a new optional
concurrency[] array. Both -race clean; forward path untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…na (§3.3)

AST source scan asserting every pool-serving oauthInject call site also calls
stashPoolPersona — catches the 归一真空 regression class (a future pool-serving path
that forgets the persona stash) that neither the behavioral test nor the runtime WARN
catches. Proven RED two ways. No GAP found: jake's NP-4 funnel routes all pooled
traffic through handleSeatGroupRoute, which always stashes persona for anthropic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine computes risk_state → GET /accounts/me/routing healthy-pick overrides,
but nothing applied them — the engine's whole routing influence was decorative. The
proxy now polls /accounts/me/routing (mirror of the group-runtime poll: 60s, same
account-JWT + control URL, keep-last-known on error) into a supervisor-scoped
atomic.Value cache, and applies the seat→account override in resolveGroupCredential
BEFORE the local ranked pick.

Fault-isolated by construction: the override runs through the SAME resolveCandidate
validity gate the ranked loop uses, so a stale override (account removed/disabled
since the engine computed it — the §6.5 member-validity re-check), no-material,
expired, exhausted, or undecryptable candidate falls through to the local seatassign
pick. Empty cache (engine down / poll not landed / feature off) → local pick. The
§11.1 invariant (engine down → local hash still serves) holds WITH the layer present.
-race clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s / test names / trial migrate)

[aikey-proxy] comment / log-string / identifier seat-group → oauth-group cleanup
(handleSeatGroupRoute → handleOauthGroupRoute etc.).

Refs:
- oauth_group rename plan (旧→新 mapping, P1–P7): https://github.com/aikeylabs/roadmap20260320/blob/d5280ecb78d50d2539da97123d8b9a6957e74eb2/技术实现/阶段7-企业生产版/20260626-oauth_group-重命名重构-分阶段实施计划.md
…-engine-signal-emit-into-seat-group

# Conflicts:
#	internal/supervisor/group_runtime_policy.go
… skip)

Follow-up to PR #2 review (side-path items; main link unaffected).

HIGH 1 — signalReporter goroutine leaked on every reload. newSignalReporter
does `go r.loop()` with no stop, and EnableSignalReporting runs in
buildGeneration for every generation, but generation.close() never stopped it.
Each reload (aikey use, filter/quota/audit toggle, /admin/reload) leaked one
loop() + 30s ticker holding a live bearer closure over the old vault reader.
Add a stop channel + idempotent nil-safe Close() (final flush then return),
expose Proxy.StopSignalReporting(), and call it from generation.close()
alongside the existing reporter/canary teardown.

HIGH 2 — routing_version == 0 first payload silently skipped. The cache's
version atomic is zero-valued, so `Version() == version` treated master's first
non-empty assignments at routing_version 0 as "unchanged" → never applied, no
signal. Add a Stored() guard (false until the first Store) so the poll skips only
once it has actually stored at this version; steady-state re-pulls skip as
before. Chose the guard over the review's "WARN on non-empty skip" suggestion:
in steady state master re-sends the same non-empty map at a stable version every
poll and we correctly skip it, so that WARN would spam every cycle.

Regressions: TestSignalReporterCloseIdempotentAndNilSafe (double/nil Close must
not panic — guards the stopOnce); TestRoutingOverrideCache_StoredDistinguishes
VersionZero (version-0 non-empty payload must apply). go build ./... clean;
internal/proxy + internal/supervisor green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(proxy): 2 HIGH review findings from PR #2 (reporter leak + routing v0 skip)
…nto-seat-group

Merge alloc-engine signal-emit + routing-override into seat-group
Delete the per-account AccountPersona normalization that disguised N pool users
as one frozen SHA256(account) identity. A frozen synthetic fingerprint can't
track Claude Code version bumps, so it drifts stale and becomes its own
detection signal — worse than a transparent proxy. Pool requests now forward
each user's real client identity unchanged; ban risk is bounded by a
≤3-users-per-account cap instead (control-master side).

- delete oauth_pool_persona.go, oauth_pool_session.go + their tests
- drop the stash/apply wiring (group_serve, forward_and_resolve, middleware,
  EventProxyGroupPersonaMissing)
- add TestGroupServe_PoolPassesRealIdentity (transparent-proxy regression guard)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(P3c proxy)

The proxy now consumes the engine's authoritative routing: the seat→account
override IS the ledger binding, and a Blocked seat (pool at the ≤3-人/号 cap) gets
429 GROUP_POOL_FULL instead of falling back to the cap-blind local pick (which
would route a 4th user onto a full account). Completes the hard ≤3 cap end-to-end.

- RoutingOverrideCache: +StoreAll(version, assignments, blocked) + Blocked(seatID)
  (Store kept, delegates → backward compatible)
- supervisor poll: parse the new `blocked` field, StoreAll it
- group_serve: Blocked(seat) → 429 GROUP_POOL_FULL (+ EventProxyGroupSeatBlocked)
- tests: poll blocked-parse + TestGroupServe_BlockedSeatReturns429

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Michael and others added 30 commits June 30, 2026 08:12
…gress

Pass cfg.UpstreamProxy.URL (the same egress AI forwarding uses) to
NewImpersonateChromeHTTPClient instead of "". launchd-spawned proxies don't
inherit the login shell's HTTP_PROXY, so the "" client went DIRECT and got
403 "Request not allowed" from Anthropic's edge — only Chrome-TLS through the
egress reaches the OAuth logic. Empty url ⇒ env fallback (no regression).
Control-plane clients still deliberately bypass egress via internal/httpx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l-resp guard

[aikey-proxy] map resolver failure to correct HTTP status (no-candidates→403, all-unusable→429, transient→503) so claude fails fast instead of backoff-hanging; nil-safe resp.StatusCode removes latent panic (SA5011)

Refs:
- Bugfix: proxy buildBaseEvent nil-resp panic: https://github.com/aikeylabs/workflow/blob/38364749b315fae856c6e7e896799c2aa3e95130/CI/bugfix/20260701-proxy-buildBaseEvent-nil-resp-panic.md
- Bugfix: team-oauth still-syncing misleading copy: https://github.com/aikeylabs/workflow/blob/38364749b315fae856c6e7e896799c2aa3e95130/CI/bugfix/20260630-团队oauth-still-syncing-误导文案.md
…fault-on sticky routing

[aikey-proxy] self-heal stale control-plane after host network change: hot-swappable client registry (internal/httpx) + proactive netmon + failure-triggered selfheal, rebuilding a fresh *http.Client across all control-plane rails (CloseIdleConnections insufficient, golang/go#23427), with a guarded graceful self-restart (exit 75) backstop

Refs:
- Bugfix: proxy control-plane stale after network change: https://github.com/aikeylabs/workflow/blob/f99795d1ac999233133fc56cc55bd7546a5317b7/CI/bugfix/2026-07-01-proxy-control-plane-stale-after-network-change.md
…ce of truth

[aikey-proxy] group runtime carries the resolved routed account consistently; add group_routed_consistency_test guarding the proxy-side pick against the snapshot

Refs:
- Bugfix: route-drift (part 2, split-truth → poolroute): https://github.com/aikeylabs/workflow/blob/c957d8ae791377abba7b9300ab55cf1a61cdf5e3/CI/bugfix/20260701-分配引擎默认开启-修复加号后路由漂移.md
… usage audit

[aikey-proxy] stamp the selected pool account's email onto the usage event as oauth_identity (point-in-time audit; routing changes over time so a read-time join would misattribute history)

Refs:
- Spec: usage-audit selected-account display: https://github.com/aikeylabs/roadmap20260320/blob/9a738488d21e7e6249c8dddca4ccaa69c54ea438/技术实现/update/20260701-用量审计冗余选中账号显示.md
…andoff, install entry unification

group member login-required bypass state file (statusline surface, cleared on next successful resolve) + routing-override self-heal persistence + routingwire adoption; TestMain guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WriteGroupRuntime / WriteConfigU64LE / WriteConfigString / ReadConfigU64LE /
ReadConfigString opened the vault with the raw dbPath, bypassing the existing
withBusyTimeoutDSN guard. modernc's default busy_timeout is 0, so any reader
briefly holding the vault lock made these writes fail instantly with
SQLITE_BUSY. The supervisor's 60s group-runtime pull cycle then aborted every
round (proxy.group_runtime.write_failed "database is locked") and group VKs
answered 503 "credentials are still syncing" for minutes — reproduced
deterministically on the Ubuntu arm64 client leg of the seat-group regression
(lock timing loses far more often on Linux than macOS, where this hid).

With the guard applied the material lands on the first pull cycle and the full
seat-group regression (select/sticky/switch via mock upstream) passes ALL
assertions on the Ubuntu VM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…degraded state surfaces to statusline

[aikey-proxy] SyncRail declarative rail driver: re-check gate/generation/credential/control-URL every cycle (was evaluate-once-at-start → 7h silent stall on server IP drift), per-rail OK→STALE→OFFLINE transitions log with the underlying error, offline-first (state = visibility only, retries never stop); migrate routing_override + group_runtime rails (quota/compliance/audit stay legacy until Phase 2); routing-override self-heal persistence; write sync-health.json for the statusline

Refs:
- Spec: control-plane SyncRail framework design: https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-控制面同步框架SyncRail-技术方案.md
- Spec: group routing-override self-heal persistence + settings linkage: https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-组路由override自愈持久化与settings联动.md
- Spec: OAuth group-member login prompt (CLI + login_url): https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-OAuth组成员登录提示-CLI显示与login_url.md
…pe 503, pool cooldown persistence, chk_mpb_target guard

[aikey-proxy] persist OAuth-pool cooldown across restart; event reporter write-reconcile-read

Refs:
- Principle: event-write-reconcile-read: https://github.com/aikeylabs/workflow/blob/fe705f43504cb9eaf9d50516984561dce71d37bd/CI/IDE/claude/principles/event-write-reconcile-read.md
…imeout guard

Review found the original fix's claim "all raw vault connections" was incomplete:
- internal/quota WriteLocalUsage (busiest vault writer, 5s ticks) + WriteSubjects
  open the SAME vault DB file raw → still SQLITE_BUSY instantly under contention.
- events DB is genuinely multi-writer (collector Insert vs retention Prune on
  separate pool conns, reload overlap, cross-process `wal vacuum`) → same gap.

- Export withBusyTimeoutDSN → vault.WithBusyTimeoutDSN (single source of truth;
  no import cycle — vault has zero internal deps, quota/events now import it).
- quota/local_usage.go + policy_write.go + events/store.go: open via it (DSN-based,
  since busy_timeout is per-connection and not persisted like journal_mode=WAL).

Verified: go build ./..., go test ./internal/{vault,quota,events} all pass; no
import cycle (go list -deps ./internal/quota lists vault; vault stays internal-free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(vault): busy_timeout guard on all raw vault connections (fixes group-VK 503 on Linux)
…4 + gateway-unreachable degrade + SyncRail/service-status/delivery-503; over vault busy_timeout fix)
…I value)

injectCodexOAuth fell back to originator=opencode (third-party tool identity)
when the client set none. Align the fallback to the official codex CLI value
(openai/codex codex-rs/login default_client DEFAULT_ORIGINATOR=codex_cli_rs).
setIfAbsent still preserves a real codex CLI's own value; this only stops
mislabelling originator-less traffic as the third-party opencode tool.

Deliberately NOT injecting a synthetic UA/session here — that identity-forgery
layer was removed 2026-06-29 for transparent proxy + <=3-users/account. Ref:
roadmap 20260705-Codex池动态调度 D8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dexUtil)

R34's reactive layer cools Codex after a 429, but the engine scored Codex blind —
util_5h/util_7d were fed only from Anthropic unified-* headers, so Codex accounts
read as always-healthy (never pre-switch, fanout stuck at max).

parseCodexUtil reads the X-Codex-* window headers off the /responses 200 (verified
live 2026-07-06) and normalizes to the SAME (util_5h, util_7d) fraction the engine
already consumes: percent/100, and 5h/7d classified by window-minutes (NOT the
primary/secondary name — not tied to a fixed window). Wired at the I5 enqueue by
header-sniff (Anthropic vs Codex util headers are mutually exclusive), mirroring
R34's reactive dispatch — no provider switch, no new endpoint/field/schema. Lights
up UtilSlope/RateOverBaseline/Peak5h/Util7d for Codex (the engine-parity delta).
Fixture test pins the real captured wire + an inverted-window adversarial case.

Ref: roadmap 20260705-Codex池动态调度 5.1/5.2/6A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ary one (D9)

codexRateLimitReset picked the primary window's reset first, assuming primary=7d
(the longer wall) per sub2api's comment. Live 2026-07-06 shows primary=5h, so when
BOTH windows were exhausted it under-cooled to the shorter wall and immediately
re-429'd. Compare reset DURATIONS directly and take the longest among exhausted
windows — sidesteps the primary/secondary naming trap entirely. Adds a D9
regression case (both exhausted, longer wall on secondary).

Ref: workflow bugfix 2026-07-06-codex-ratelimit-reset-window-by-name.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ban parity)

windowPreCutDecision only read anthropic unified-* util, so Codex pool accounts
got NO proactive 防封 pre-cut (reactive 429 only). Add a codex branch (header-sniff,
anthropic path byte-identical): any X-Codex-*-used-percent >= the master cap →
pre-cut until THAT window's reset, preferring the absolute X-Codex-*-Reset-At
(verified live 2026-07-06) over the relative reset-after-seconds. No 5h/7d
classification — cool until the over-cap window's own reset (same insight as the
D9 fix). observedResetEpoch also gains a codex branch (soonest reset-at) so
master re-rolls the randomized cap per window. Fixture tests pin the live wire.

Pairs with the master change that rolls a cap for codex (D2). Ref: roadmap
20260705-Codex池动态调度 §5.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eQuotaUsage

Codex /responses SSE usage frames omit the model, leaving TokenBreakdown.Model
empty so the edge-summary usd lookup missed (usd=0) even though reportUsage
already recorded the resolved model. Backfill breakdown.Model from the resolved
model before quota accrual on both streaming and non-streaming paths. Server-side
billing was already correct; this fixes the proxy's local in-flight usd estimate.

Bugfix: workflow/CI/bugfix/2026-07-06-codex-pricing-summary-and-model-extraction.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex into OAuth account pool: mechanism + dynamic scheduling + pricing/login fixes
…ource-scan fence

WriteAssignmentOverride was the single raw sql.Open left behind by the
2026-07-03 busy_timeout sweep (R2). With modernc's default busy_timeout=0
the UPDATE fails instantly with SQLITE_BUSY under any concurrent vault
reader and the routing override is silently dropped.

Adds TestNoRawSQLOpenInVault: scans every production file in the package
and fails on any sql.Open("sqlite", ...) whose DSN is not wrapped with
WithBusyTimeoutDSN — this class regressed twice via hand-audit misses, so
the fence is structural rather than reviewer memory.

Found by 2026-07-07 Windows/Mac parity audit (P1-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The proxy runs console-less on Windows, so a console-subsystem app-hook
child (e.g. ai-compliance-detector) popped a visible terminal window for
its whole lifetime. Applies aikeycompat.HideSpawnConsole (no-op on Unix;
stdio pipes unaffected). Same class as the 2026-07-07 web-bridge
window-flash bug; found by the lateral sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ormat

Codex sends the Responses API (/v1/responses: instructions + input[],
SSE frames response.output_text.delta, end marker response.completed) —
but the conversation-audit extractor only understood classic Chat
Completions (messages[], choices[].delta.content, [DONE]). Every Codex
turn extracted to empty and OnRequestEnd skipped the record with
CONTENT_EMPTY_EXTRACT (live incident 2026-07-07: team usage recorded,
conversation audit empty — usage's own extractor already knew both
formats, provider/openai.go documents the same split).

Structure per user decision (多态 + 可扩展 + 旧 openai KEY 零影响):
the openai_compatible family now owns an ordered wire-format probe
table (openaiWireFormats) where each format is one entry with its three
probes (prompt / delta / done). Chat Completions probes FIRST, keeping
existing deployments byte-identical; the next OpenAI variant is one
appended entry plus fixtures, no shared-flow edits. The completion
marker fix also stops Responses turns from mislabeling as "partial".

Fixtures: Responses item-array + string-input + delta/done frames,
chat-completions-wins-when-messages-present, bare-model fallback; all
pre-existing chat/completions fixtures untouched and green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ribution)

RequestContext + ConversationRecord gain SeatID from route.SeatID — the
exact field usage events already carry (reportable.go). Why: for a
shared OAuth-pool VK, route.AccountID is the VK OWNER (pool creator),
so audit records keyed only on owner_account_id filed an employee's
codex conversations under a stranger seat row while their usage
attributed correctly (live incident 2026-07-07, org 624a2488; same
root as the session_xxx phantom seats seen 2026-06-17). Collector /
query / web legs follow in their repos; legacy rows keep owner-only
attribution (query falls back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lind)

Live incident 2026-07-08: a codex message containing "fuck" reached the
model unscanned — proxy logged "no filterable content extracted;
forwarded UNFILTERED" while the SAME message was captured by the
conversation audit. Root cause: the compliance filter's content
extractor (extractUserContent / extractFilterableContent) only
understood Anthropic /v1/messages + OpenAI /v1/chat/completions
(messages[]), but codex sends the Responses API (/v1/responses:
instructions + input[] with input_text content parts). Unhandled →
zero pieces → forwarded unfiltered → no masking, no compliance event.

Same Responses-API wire-format class as the conversation-audit R20 fix,
but the compliance filter is a SEPARATE code path that was still blind
to it (audit and filter both needed the fix; only audit had it).

- extractUserContent: falls back to input[] (item array) / input string
  when messages[] is absent, reusing the same user-turn skip policy via
  new collectUserTurns; instructions skipped like system.
- collectContentField: accepts input_text blocks (Responses user parts)
  alongside text; output_text (model reply) still skipped by role.
- extractFilterableContent (full extractor) kept coherent.
- Fences: Responses input-array (scans user turns, skips instructions +
  assistant output), plain-string input, and mask write-back round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…solution)

The master compliance-audit page resolves a seat alias/email from the
event's seat identity, but the proxy only stamped tenant_id +
virtual_key_id — the event's user_id is the detector's metadata.user_id
(a Claude/session id), which for a shared OAuth-pool VK is NOT a seat, so
pool-VK events showed a raw id instead of the employee's alias (same root
as the conversation-audit seat fix). New injectSeat mirrors
injectVirtualKey, stamping route.SeatID; applyInboundFilter gains a
seatID param threaded from route.SeatID. Fence: TestInjectSeat (stamps
seat_id, preserves detector user_id per decision A, empty/bad-json
fail-safe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-link)

injectSession stamps route's resolveSessionID onto team compliance events
— the SAME session source the conversation-audit observer uses — so the
compliance-audit drawer can deep-link to the exact conversation thread
for a flagged prompt (event_id joins the turn, session_id opens its
thread). Empty session (codex/no-session header) → unchanged; the UI
keys those threads by event_id. applyInboundFilter gains a sessionID
param. Fence: TestInjectSession. Decision 2a, 2026-07-08.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…v1-128k default

[aikey-proxy] new internal/sysproxy (read OS system-proxy darwin/windows/other); egress auto-follows system-proxy switch; upstream_proxy/admin/config wiring + egress integration test

Refs:
- Spec: 上游代理-系统代理自动感知刷新: https://github.com/aikeylabs/roadmap20260320/blob/051ecf5/技术实现/update/20260708-上游代理-系统代理自动感知刷新.md
…flight) + egress two-lane refinement

[aikey-proxy] egress two-lane decision-chain (env-inherit degrade) refinement on sysproxy; admin/upstream_proxy wiring + egress integration test

Refs:
- Spec: 出口决策链-继承env降级细分: https://github.com/aikeylabs/roadmap20260320/blob/1b5f896/技术实现/update/20260708-出口决策链-继承env降级细分.md
- Principle: outbound-egress-two-lane: https://github.com/aikeylabs/workflow/blob/8a922c0/CI/IDE/claude/principles/outbound-egress-two-lane.md
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.

2 participants