Compare commits

...
Author SHA1 Message Date
elky 214f3d6406 fix(providers): hide billing fields in provider form 2026-09-02 23:17:01 +08:00
elky 7323d41fbe feat(routing): move sticky-key retries into routing policy with lazy attempts
Replace the provider/endpoint max_retries fields as the source of same-key
retries with a routing policy setting, sticky_key_attempts (default 2). Only
the first-ranked candidate is retried on the same key; every failover
candidate gets a single attempt so failover keeps advancing instead of
retrying each fallback key.

Materialize exactly one attempt per candidate and derive same-key retries in
the attempt loop after a candidate-scoped failure, so the retry budget no
longer inflates up-front materialization and needs no upper bound. The budget
travels in the report context; retries reuse the plan with a fresh candidate
id and incremented retry index. Pool groups only retry their first key within
the retry-index stride.

Expose the setting in the routing profile editor and the set_scheduling rule
action, and drop the max_retries input from the provider form.
2026-09-02 20:48:40 +08:00
elky 415b2da81b feat(routing): make routing profiles the sole scheduler policy source
Bootstrap an enabled system-default routing group from the legacy
scheduler config keys on startup, resolve the default ordering config
from that group before falling back to the legacy keys, and stop merging
keep_priority_on_conversion with the legacy flag when a policy is
resolved. Thread the policy-derived ordering config into candidate
preselection so it no longer reads system config independently.

Add per-API-format key priority overrides so a key serving several
formats keeps independent ordering, matching the legacy
global_priority_by_format semantics. Expose keep_priority_on_conversion
in the routing profile editor and read the effective policy in the
model routing preview, monitoring metrics and provider page badge.
2026-09-02 17:04:04 +08:00
elky 166236c2ee Merge origin/main into CI dependency fix 2026-09-02 12:30:31 +08:00
ZheFox 0371a961d6 Merge pull request #777 from zhefox/main
Fix detection of DeepSeek custom relay models in gateway
2026-09-02 11:24:55 +08:00
ZheFox 144a28f544 feat(admin-users): add plan entitlement revocation flow 2026-09-02 11:23:24 +08:00
elky 611c29f1f5 ci: install VSCodex web dependencies in nightly build 2026-09-02 11:17:06 +08:00
ZheFox 6540c1e46a Merge branch 'fawney19:main' into main 2026-09-02 10:26:07 +08:00
ZheFox 24bf92a8bf Merge pull request #776 from zhefox/fix/deepseek-reasoning-text-replay
fix(gateway): detect DeepSeek custom relay models
2026-09-01 23:04:51 +08:00
zhefox 7ae984df4b fix(gateway): detect DeepSeek custom relay models 2026-09-01 22:49:35 +08:00
zhefox e2154629ca fix(gateway): detect DeepSeek custom relay models 2026-09-01 22:49:25 +08:00
ZheFox 0bfd48b9db Merge pull request #774 from Kayphoon/codex/fix-openai-responses-ping
fix(formats): ignore Responses ping stream events
2026-09-01 22:17:18 +08:00
Kayphoon 88d2b002be fix(formats): ignore Responses ping stream events 2026-09-01 12:58:21 +00:00
fawney 30a75832f8 feat(vscodex): add remote Codex collaboration module 2026-09-01 20:25:35 +08:00
ZheFox 5a69cfe40d Merge pull request #772 from zhefox/main
fix(gateway): handle pool saturation and malformed Gemini calls
2026-09-01 19:31:44 +08:00
ZheFox 3d87bbf230 style(rust): apply workspace formatting 2026-09-01 19:31:13 +08:00
ZheFox 633363e190 fix(gateway): handle pool saturation and malformed Gemini calls 2026-09-01 19:25:00 +08:00
fawney19 715f2773c3 Merge pull request #773 from fawney19/codex/codex-fingerprint-convergence
refactor(codex): generalize fingerprint convergence
2026-09-01 17:27:04 +08:00
elky d07dc86376 refactor(codex): generalize fingerprint convergence 2026-09-01 17:05:54 +08:00
elky ef7caa40e7 ci: publish nightly builds from main 2026-09-01 16:43:29 +08:00
fawney19 7fb8d5fc0a Merge pull request #771 from fawney19/codex/codex-context-stability
Merge Phase 1 Codex context and fingerprint convergence changes.
2026-09-01 16:07:41 +08:00
elky 3e540ce589 fix(gateway): route Codex context through transport facade 2026-09-01 15:53:47 +08:00
ZheFox 6c71f87589 fix(frontend): align Antigravity quota summaries 2026-09-01 15:38:00 +08:00
elky a39048ecce feat(codex): stabilize identity across retries 2026-09-01 15:33:40 +08:00
ZheFox b538aa2d66 Merge pull request #770 from zhefox/main
fix(pool): show Antigravity quota reset times
2026-09-01 12:09:41 +08:00
ZheFox 57abb20778 fix(pool): show Antigravity quota reset times 2026-09-01 11:29:48 +08:00
ZheFox d117a0cd13 Merge pull request #769 from zhefox/main
fix(pool): restore Antigravity quota progress bars
2026-09-01 10:43:05 +08:00
ZheFox ee55f46962 fix(pool): restore Antigravity quota progress bars 2026-09-01 10:40:37 +08:00
ZheFox 9210502e77 Merge pull request #768 from zhefox/main
fix(pool): isolate model quotas and compact account display
2026-09-01 10:20:09 +08:00
ZheFox 2fe2600021 fix(pool): isolate model quotas and compact account display 2026-09-01 10:15:57 +08:00
ZheFox 9b819169d5 Merge pull request #767 from zhefox/fix/provider-key-concurrency-cache-affinity
fix(gateway): improve provider pool concurrency, quotas, and affinity
2026-09-01 08:11:46 +08:00
ZheFox 9631b229b3 fix(gateway): add provider key concurrency and cache affinity modes 2026-09-01 08:06:58 +08:00
ZheFox 9372d6cfa5 Merge pull request #765 from Brisbanehuang/codex/usage-api-template
feat(provider-ops): 新增通用 API Key 用量查询模板
2026-08-29 23:23:06 +08:00
Brisbanehuang 4dbf98163e feat(provider-ops): add generic usage API template 2026-08-29 09:04:05 -04:00
ZheFox 6ec0771297 Merge pull request #764 from zhefox/main
fix(gateway): route Responses compaction only to Responses providers
2026-08-29 12:56:32 +08:00
zhefox 56395945c0 fix(gateway): route Responses compaction only to Responses providers 2026-08-29 12:28:55 +08:00
ZheFox 8032497045 Merge pull request #763 from zhefox/main
Fix response reasoning summaries for Chat
2026-08-29 11:54:15 +08:00
zhefox b35364d7fd fix(antigravity): normalize private search tool name 2026-08-29 11:09:15 +08:00
ZheFox f085fdf918 Merge pull request #762 from zhefox/main
fix(gemini): normalize mixed tools for same-format providers
2026-08-29 08:48:56 +08:00
ZheFox 36daba7a34 fix(antigravity): align tool schema wire fields 2026-08-29 08:45:19 +08:00
ZheFox 9837ce1197 fix(antigravity): use Gemini schema field for tools 2026-08-29 02:50:07 +08:00
ZheFox 1bc2287baa fix(gemini): normalize mixed tools for same-format providers 2026-08-29 01:08:01 +08:00
ZheFox a519bcf705 Merge pull request #761 from zhefox/main
Fix response reasoning summaries for Chat
2026-08-29 00:03:16 +08:00
ZheFox 9461b1004f Merge branch 'main' of https://github.com/zhefox/Aether 2026-08-29 00:00:13 +08:00
ZheFox 3c15f523be Merge pull request #760 from zhefox/fix/gemini-tool-wire-model-gating
fix(formats): gate mixed Gemini tools by model
2026-08-28 23:36:08 +08:00
ZheFox 5bcdcca784 fix(formats): normalize Responses additional tools for Chat 2026-08-28 23:03:56 +08:00
ZheFox 83098f98b6 fix(formats): gate mixed Gemini tools by model 2026-08-28 20:42:49 +08:00
ZheFox 5ab35ae6ba Merge pull request #759 from zhefox/fix/gemini-tool-schema-compat
fix(formats): enable mixed Gemini tool calls
2026-08-28 17:58:03 +08:00
ZheFox f0b0064f3d fix(formats): enable mixed Gemini tool calls 2026-08-28 16:55:38 +08:00
ZheFox 4879295f23 Merge pull request #758 from zhefox/fix/gemini-tool-schema-compat
fix(formats): sanitize Gemini tool schemas
2026-08-28 16:15:24 +08:00
ZheFox 64e5725331 fix(formats): sanitize Gemini tool schemas 2026-08-28 16:11:16 +08:00
ZheFox 1995198b18 Merge pull request #757 from zhefox/fix/responses-chat-reasoning-summary
fix(formats): degrade Responses reasoning summaries for Chat
2026-08-28 14:09:57 +08:00
ZheFox 5b6fce1a77 fix(formats): degrade Responses reasoning summaries for Chat 2026-08-28 14:05:48 +08:00
ZheFox fa8e443f7b fix(formats): degrade Responses reasoning summaries for Chat 2026-08-28 14:03:43 +08:00
ZheFox 08e7530adb Merge pull request #756 from zhefox/main
fix: preserve Gemini signatures and enforce provider limits
2026-08-28 14:01:43 +08:00
ZheFox 5687dad177 fix(formats): scope signature helper to tests 2026-08-28 13:21:35 +08:00
ZheFox dd2958a458 fix(admin): persist provider settings and enforce quotas 2026-08-28 12:47:54 +08:00
ZheFox c4b4dfa996 fix(formats): preserve Gemini tool thought signatures 2026-08-28 12:47:34 +08:00
ZheFox d88c454a2c Merge pull request #754 from zhefox/main
fix(ai): align Gemini and Responses compatibility
2026-08-28 08:15:35 +08:00
ZheFox 8cdfa338e5 fix(gemini): pair idless tool history 2026-08-28 02:15:00 +08:00
ZheFox 4da8c57fe3 fix(ai): align Gemini and Responses compatibility 2026-08-27 22:13:20 +08:00
ZheFox 7892aa9485 Merge pull request #753 from zhefox/fix/codex-namespace-tool-conversion
fix(formats): preserve Responses namespace tools through Chat
2026-08-26 01:13:53 +08:00
ZheFox 9d9892be6a fix(gateway): finalize cross-format sync JSON responses 2026-08-26 00:43:58 +08:00
ZheFox e2b003af24 fix(formats): preserve Responses namespace tools through Chat 2026-08-26 00:43:50 +08:00
ZheFox ffca7e0402 Merge pull request #752 from zhefox/main
fix(codex): isolate Spark quotas and repair contaminated account state
2026-08-25 22:13:15 +08:00
ZheFox ec6ddb43a7 fix(data): retain applied legacy backfill for upgrades 2026-08-25 21:22:57 +08:00
ZheFox 2f2d444f97 fix(codex): self-heal Spark-contaminated account quotas
Keep model-scoped Spark windows out of account state, preserve authoritative WHAM exhaustion flags, and repair historical cross-family quota generations without weakening stale-response guards.
2026-08-25 19:13:41 +08:00
stabeyandClaude Opus 5 42deab67b3 fix(admin): keep a model-scoped 429 reset out of the account quota slot
`parse_codex_websocket_usage_limit_error` backfills `primary_reset_at` and
`primary_reset_after_seconds` from the error body whenever the embedded headers
did not supply them. Now that a named per-model limit no longer claims the
unprefixed window headers, that absence is exactly what a Spark 429 produces —
and `resets_at` on such an error is the Spark window's reset, so the backfill
put model-scoped timing back on the account's own quota.

Gate the backfill on the same ownership rule the window parsing uses.

Reported by Cursor Bugbot on the fork PR.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 15:22:29 +08:00
stabeyandClaude Opus 5 1b1be918a9 fix(admin): keep Codex model-scoped rate-limit headers out of the account quota
`parse_codex_usage_headers` reads the unprefixed `x-codex-primary/secondary-*`
headers as the account's own quota. They are not: they carry whichever limit
governed the request, and `x-codex-active-limit` names it — `premium` for the
plan's own limit, or a metered feature such as `codex_bengalfox` for a named
per-model limit. On a request billed against a named limit the unprefixed
headers repeat that limit's windows verbatim.

So a single request to a model with its own limit writes that model's windows
into the account slots. The paid-window swap then makes it worse: a named
limit's secondary window is active, unlike the plan's disabled one, so the swap
promotes the model's weekly window into the account's weekly slot — the slot
the UI labels and the scheduler reads through `quota_usage_ratio`.

It also sticks. Both weekly windows share `window_minutes`, so
`codex_quota_same_window_identity` treats them as one window, and
`codex_quota_merge_same_window` drops an observation whose deadline is earlier
than the stored one. The two weeks start at different instants, so every later
account observation looks like a stale sample of a window that already rolled
over and is discarded until the model window's own deadline passes.

Observed on a `pro` key running both model families: one `gpt-5.3-codex-spark`
request replaced the account weekly window with the Spark weekly one, and the
~3000 plan-limit responses over the next 100 minutes were all discarded. The
account's real weekly usage never landed, and its reset time was reported nine
hours late.

The header set describes itself — every named limit announces
`x-codex-<feature>-limit-name` and carries its windows under the same prefix —
so parse the named families directly and only claim the unprefixed windows for
the account when no announced limit owns them. Responses without
`x-codex-active-limit` keep the previous behaviour.

This also stops the Spark windows from going stale: they were only ever written
by the `wham/usage` admin probe even though every response carries them.

Refs #746

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 15:22:29 +08:00
ZheFox 3f2b67f191 Merge pull request #749 from zhefox/main
test(gateway): fix Codex Realtime route fixture
2026-08-23 12:48:10 +08:00
ZheFox 6a9eea34a0 test(gateway): fix Codex Realtime route fixture 2026-08-23 04:00:16 +08:00
ZheFox 453a0b3ee7 Merge pull request #747 from zhefox/main
fix(gateway): support current Codex Realtime live routes
2026-08-23 03:18:46 +08:00
ZheFox 2cd20da1ec fix(gateway): support current Codex Realtime live routes 2026-08-23 02:51:49 +08:00
ZheFox ea4453321d Merge pull request #743 from zhefox/main
fix(usage): unify OpenAI Live and WebSocket records
2026-08-21 12:47:37 +08:00
ZheFox acde38b8e7 fix(usage): unify OpenAI Live and WebSocket records 2026-08-21 11:53:53 +08:00
ZheFox 9996e75a34 fix(ci): align database snapshot migration cutoff 2026-08-21 11:11:45 +08:00
ZheFox 16f96d73ec Merge pull request #742 from zhefox/main
feat(gateway): add Codex Live and OpenAI Realtime
2026-08-21 08:36:19 +08:00
ZheFox 2c89202001 feat(gateway): add Codex Live and OpenAI Realtime
Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
2026-08-21 04:27:34 +08:00
ZheFox fe38dcd294 Merge pull request #741 from zhefox/main
feat(gateway): add Codex Live transport
2026-08-20 22:22:37 +08:00
ZheFox 4185ad1b1e feat(gateway): add Codex Live transport 2026-08-20 21:59:05 +08:00
ZheFox 6916e9da76 Merge pull request #739 from zhefox/main
fix(ws): secure Responses continuation and opaque reasoning replay
2026-08-20 10:01:46 +08:00
ZheFox 654f798d25 fix(ws): harden Responses continuation state 2026-08-20 08:51:16 +08:00
ZheFox bef282cfee fix(responses): replay DeepSeek opaque reasoning state 2026-08-20 00:40:48 +08:00
ZheFox d21d8ce9f5 fix(codex): avoid replaying static config on websocket continuations 2026-08-20 00:40:40 +08:00
ZheFox 342f8b6a5f Merge pull request #737 from zhefox/main
fix(ws): preserve Codex continuations across turn metadata
2026-08-18 18:20:49 +08:00
ZheFox c50a1c6c46 fix(ws): preserve Codex continuation bindings 2026-08-18 17:17:45 +08:00
elky 535ee098c3 fix(auth): reject unsigned admin identity headers 2026-08-18 11:12:17 +08:00
ZheFox b45df89ce4 Merge pull request #730 from zhefox/fix/responses-websocket-current
feat(gateway): add OpenAI Responses WebSocket mode
2026-08-17 21:14:50 +08:00
ZheFox c8118edf36 fix(ws): harden Responses connection lifecycle
Revalidate control policy per turn, isolate downstream credentials, and make planner/turn ownership cancellation-safe.

Preserve opaque protocol events, align configurable timeout semantics, and extend end-to-end security and settlement coverage.
2026-08-17 18:50:29 +08:00
AAEE86 4a0775c4ea style: apply cargo fmt across gateway and aether-ai crates 2026-08-17 14:53:53 +08:00
AAEE86 6fc02dad3e fix(ws): restore redacted PII in provider frames before client delivery
Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。

- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
  之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
  位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
  RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
  映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
  因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
  持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
  增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
  因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
  「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
  都不做。

测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
2026-08-17 14:53:46 +08:00
AAEE86 dbf2809bd6 fix(ws): settle the previous attempt before transparent retry replanning
评审第 2 条。配额透明重试原来的顺序是「detach 旧 attempt → 规划并绑定新
attempt → 把旧 attempt 的结算排进队列」。规划因此读到的是旧 attempt 还没投射的
health / adaptive / pool 状态,而且旧 attempt 仍占着自己的 pool key lease——替代
key 的挑选看到的是一把仍被占用的 key,最坏情况下判成「无可用供应商」而放弃一次
本可以成功的重试。

普通的新 turn 早就挡住了这件事:client.rs 在处理 response.create 前调用
await_pending_turn_finalization,注释写的正是「不要让新 turn 基于陈旧的 health /
adaptive / pool 状态规划」。透明重试是同一个问题的另一条入口,漏了这一步。

现在顺序是:detach → 释放准入 → 结算旧 attempt 并等它落地 → 规划/绑定新 attempt。

新增 lifecycle::settle_turn_finalization:与 queue_turn_finalization 的区别只在于
「等」。后者把 handle 挂在连接上让 relay loop 继续跑,用在结算之后不再读取共享
状态的出口;前者用在必须先看到结算结果才能继续的路径上。

顺序用类型固定,而不是靠注释:settle_turn_finalization 返回
PreviousAttemptSettled,retry_active_turn_after_quota_exhaustion 要求这个参数。
凭证只能由 lifecycle 颁发(结算完成,或明确「没有 attempt 要结算」),所以把顺序
写反连编译都过不了。

重试失败路径随之变化:旧 attempt 已经结算,不再 resume 回去。logical turn 仍停在
Replanning,后续分支的 end() / finalize_active_turn 只清 logical turn、不交出
attempt,因此不存在重复结算。结算 outcome 取值不变(两条路径用的都是
terminal_outcome.unwrap_or_else(upstream_closed),而这条分支里 terminal_outcome
必为 Some——usage_limit_error 成立意味着有一个已解析的 error 终态帧)。

代价(都落在「重试失败」这一侧,且只影响已终态 attempt 的报告注解,不影响计费):
- 那条最终转发给客户端的 429 事件不再进旧 attempt 的 client capture;
  provider 侧 capture 早在 observe_upstream_frame 里就记下了。
- 如果转发 429 给客户端也失败,record_client_delivery_aborted 落在一个已经结算的
  attempt 上,成为 no-op。

测试:
- lifecycle:await_turn_finalization_handle 必须「等到落地」而不是「排进队列」
  (C6 依赖的性质);结算完成后规划才读状态的顺序型断言(计数器替身);结算任务
  panic 也必须放行调用方,不能卡死 relay loop。
- turn_state:Replanning 状态下 end() 不再交出第二个 attempt(无重复结算)。
- e2e 新增 provider_quota_exhaustion_transparently_retries_onto_another_key:
  mock 上游首轮只回 Codex 的 429 usage_limit_reached,网关换到第二把 key 重放同一个
  response.create;断言客户端看不到 429、上游被连两次、两次用的不是同一把 key、两个
  attempt 各留一条终态行(429 的那条 + 计费的那条)。已验证它在改动前后都通过——
  它覆盖的是整条路径可用,顺序由上面的单测确定性覆盖。
  夹具随之参数化出 ProviderFixture::CodexKeyPair:透明重试只有 Codex adapter 会
  开启,而 codex 候选要求 auth_type = oauth,所以这个夹具用未过期的 oauth 凭证。
2026-08-17 14:53:40 +08:00
AAEE86 1d3051cb89 refactor(ws): structured terminal observation without SSE text round-trips
评审第 5 条:Responses WebSocket 收到的本来就是结构化协议事件,但为了复用面向
SSE 的 push_line,观测路径要先把每个事件序列化成 data: {json}\n\n,解析器再
decode 回 Value——一次纯粹的往返。这个「伪 SSE」形状是随手拼的,一旦拼装函数
以后被加上换行或分块逻辑,观测结果就会和真实事件悄悄分叉。

aether-ai-formats:
- OpenAIResponsesProviderState::push_line 机械拆成 decode + push_event,
  push_line 现在只做解码。协议状态机一行未动,diff 里除函数签名外只有
  &value → value(value 从拥有改成借用,持有结构化事件的传输不必为了调用它
  先克隆一份)。
- StreamingStandardTerminalObserver::push_event 走 TerminalStreamParser::Standard,
  service tier 的记录方式与 push_line 完全相同。openai:image 的终态状态机按 SSE
  行做增量解析、没有结构化入口,返回 AiSurfaceFinalizeError 让调用方
  disable_with_error 标记 parser_error,而不是静默丢事件、把摘要留成「未观察到
  终态」。ProviderStreamParser 的其余三个格式同样返回 Err:机械拆分随时可做,
  但不建无调用方的接口。

WS 侧:
- 新增 responses/observation.rs 的 ResponsesStructuredTerminalObserver,直接消费
  frame.protocol_events() 借出的事件。包一层的意义是让「不再拼 SSE」成为类型层面
  的事实——这个类型没有任何接受字节的方法,改回 push_line 不可能悄悄发生。
  finish() 里的 Ok(None) / Err → disable_with_error 兜底也一并收进来。
- body capture 不动,仍然是 SSE 形状(data: 开头、\n\n 结尾):
  aether_usage_runtime::report 用 line.strip_prefix("data:") 解析被捕获的 body
  判定 StreamCapturedTerminalState,而它是 stream_report_represents_failure 的一个
  OR 项,换成结构化 JSON 会让终态判定恒为 Missing。capture_sse_event /
  capture_client_frame / websocket_event_as_sse_line 全部保留,原因写在模块文档
  注释里。这一层只换观测,不换捕获。

差分测试(8 个,aether-ai-formats):同一组事件序列分别走 push_line 与
push_event,断言 ExecutionStreamTerminalSummary 完全相等——批量 delta 序列、
completed 带 usage、合法 incomplete、error、response.failed、未知事件、
service tier、缺终态;外加 openai:image 拒绝结构化入口。两条入口不可能有
过滤差异:任何 Value 序列化出来都不会命中 decode_json_data_line 的 empty /
":" / "event:" / [DONE] 四个过滤条件。

turn.rs 里三个既有的 WS 观测测试改走结构化入口;SSE 形状的断言留在 capture 一侧。
验收:crates/aether-usage 零 diff。
2026-08-17 14:53:33 +08:00
AAEE86 59e27524da refactor(gateway): extract transport-neutral execution attempt lifecycle
评审第 4 条:responses/turn.rs 实际复制了一整套 HTTP execution lifecycle——
usage 写入、candidate 状态流转、health/adaptive 效果投射、pool key lease 释放、
body capture、账单失败判定,与 HTTP 的顺序和超时语义只能靠人工对齐。

新增 execution_runtime/attempt_lifecycle.rs,把一次 provider attempt 的记账收成
transport 中立的三段:

  ExecutionAttemptLifecycle::begin        pending usage 行 + Pending candidate
  ExecutionAttemptLifecycle::mark_started usage stream_started + Streaming candidate(幂等)
  ExecutionAttemptLifecycle::settle       终态四段,顺序不可重排:
                                            1 usage terminal(detachable,不可丢)
                                            2 candidate terminal
                                            3 provider 效果 + 超时兜底释放 lease
                                            4 execution report(作废账单不提交)

顺序、5s 分段超时常量、detachable 语义、「每个效果分支都释放 lease」「作废账单
一律不提交 report」这些不变量全部保持原样。

一并上移的辅助设施:
- AttemptStageGuard 取代 await_websocket_lifecycle_stage /
  await_detachable_lifecycle_stage,把「等多久」参数化:WS 用 Bounded(5s),
  HTTP 接线时用 Unbounded 即保持它现在的语义。
- AttemptBodyCapture 取代 append_capture / encode_stream_capture,把
  「缓冲 + 截断标志」两个字段收成一个类型(WS 侧四个字段变两个)。捕获内容
  仍然是 SSE 形状:usage runtime 按 data: 行解析被捕获的 body 来判定
  StreamCapturedTerminalState,换成结构化 JSON 会让终态判定恒为 Missing。
- C2/C3 的结算表本来就不含任何 WS 类型,随之上移。效果表分支与注释逐字未改,
  仅按新位置改名为 AttemptProviderEffect / classify_attempt_provider_effect。
  responses/settlement.rs 只保留 WS 专属的一件事:把 relay loop 的结算信号
  ResponsesWebSocketTurnOutcome 翻译成两个正交事实。

ResponsesProviderAttempt 现在只持有 WS 专有状态:lifecycle 句柄、deadline、
终态观测器、两侧 capture、准入、provider/delivery 事实。plan / trace_id /
report_kind / report_context / candidate 起始时间戳都归 lifecycle。

HTTP 侧不接线:execution_runtime/stream/execution.rs 的
DirectPassthroughFinalizerCore(38 字段)与 failover / oauth 重试 / prefetch 深度
纠缠,无法在「行为等价 + 单 commit 可验证」的前提下改动。逐调用点映射表写在
模块文档注释里作为后续 PR 的接线依据。验收:git diff 对
execution_runtime/stream/ 与 crates/aether-usage 均为零 diff。

新增 6 个测试:效果段超时后仍走兜底 lease 释放、Unbounded 会一直等、detachable
写入在调用方停止等待后仍跑完、settle 四段顺序(计数器替身)、body capture 的
SSE 形状与编码状态(并显式记录默认上限是 usize::MAX,截断分支不可达)、
candidate error_type 映射。
2026-08-17 14:53:25 +08:00
AAEE86 247e7105a2 fix(ws): bill a provider-reached terminal even when client delivery fails
评审第 5 条后半:provider 终态已经到达、只是 gateway 写客户端 socket 失败时,
relay loop 用 client_disconnected() 覆盖了结算信号,于是一条供应商已经完成推理
并消耗了 token 的响应被记成 void billing、candidate 记 Cancelled、不投射供应商
效果、也不提交 execution report。上游成本凭空消失。

结算表只改一行:作废账单的条件从
    provider.cancelled_by_provider() || delivery.is_aborted()
收紧为
    provider.cancelled_by_provider() || (delivery.is_aborted() && !provider.is_terminal())

于是 Terminal{cancelled=false} + delivery Aborted 与 delivery Complete 落在同一侧:
Billed、candidate Success 或 Failed、投射供应商效果、提交 execution report。
状态码随之变成纯 provider 事实(不再把 200 改写成 499);作废分支的 provider
状态码本身就是 499,取值不变。

依据:供应商已经完成推理并消耗 token,客户端还能用 previous_response_id 续取
这条响应。供应商没给出终态时(客户端先走了)仍然作废,这一侧未改。

配套改动:
- connection.rs 写客户端失败处改为 record_client_delivery_aborted(reason) +
  settle_signal_for_client_delivery_failure(terminal_outcome):provider 终态已到达
  就用那条终态作结算信号,不再无条件覆盖。投递失败原因也不再谎称
  「客户端在终态前断开」。
- 投递结果记在 attempt 上而非 logical turn 上:结算按 attempt 进行,且配额透明
  重试时各 attempt 的投递结果彼此独立。
- report_context 新增 websocket_client_delivery="aborted" 与
  websocket_client_delivery_reason,只增字段不改既有字段,便于事后区分
  「客户端拿到了」和「客户端没拿到但已计费」。
- candidate error_type 新增 client_delivery_failed(原先这个场景写的是
  websocket_cancelled)。它排在供应商侧分类之前:这条记录之所以特别正是因为
  内容没送到客户端,供应商侧判定仍由 candidate_status 与 error_message 保留。
- finish_summary 改用作废判定而非「投递失败」判定:provider 终态已到达时摘要
  必须保留真实的 finish_reason 与 usage,否则计费记录会被写坏。

e2e 期望值变化:client_disconnect_mid_turn_still_settles_the_usage_row 改名为
client_disconnect_before_any_provider_output_settles_a_void_row,并补上
「不计费 + status=cancelled + status_code=499」的断言。原用例的 mock 行为是
StallAfterCreated(只发 response.created 就静默),provider 从未给出终态,所以
它走的是未改动的作废一侧;原来的文档注释说「must still be billed」与实际语义
不符,一并纠正。真正被修正的那一行无法在 e2e 里确定性触发——它取决于 relay
loop 的 select! 先观察到上游终态帧还是先观察到已关闭的客户端 socket,是构造性
竞态——因此由 relay 级单测确定性覆盖,e2e 里以注释指向这两个单测。

新增 7 个测试:结算表修正行(并与「投递成功」逐字段对照,只有 candidate 错误
分类不同)、无终态时仍作废、供应商声明取消即使送达也不计费、结算信号选择、
已记录的投递失败不被结算信号覆盖、relay 级「终态到达 + 客户端已关闭 ⇒ Billed /
Success / ProviderSuccess / 已提交 report 且 usage 完整保留」及其镜像、
report_context 只增不改。
2026-08-17 14:53:12 +08:00
AAEE86 dc3743aecf refactor(ws): 拆分 LogicalTurn 与 ProviderAttempt,结算改表驱动
评审第 5 条:一个 ResponsesWebSocketTurn 同时代表 logical turn 和 provider
attempt,finalize() 又用 outcome.cancelled() 一个布尔驱动 billing、candidate
状态和供应商效果,于是 provider 终态已经到达、只是最后一跳写客户端失败时,
供应商事实会被 Cancelled 覆盖掉。

- ResponsesWebSocketTurn → ResponsesProviderAttempt,
  ActiveResponsesWebSocketTurn → ActiveProviderAttempt:类型名字明确它只代表
  一次上游执行,logical turn 由 C1 落地的 LogicalTurn 承担。
- 新增 settlement.rs:AttemptProviderOutcome × AttemptClientDelivery 两个正交
  事实,classify_attempt_settlement 一张表推出 status_code / billing /
  candidate 状态 / candidate 错误分类 / 供应商效果 / 是否提交 execution report。
- attempt 观察到 provider 终态即记录 provider_outcome。结算信号
  ResponsesWebSocketTurnOutcome 只回答「为什么现在结算」:ProviderTerminal 与
  Failure 对 provider 是权威的,Cancelled 只描述客户端/连接层面的停止,不再
  覆盖已观察到的 provider 事实。
- candidate 状态与 candidate 错误分类分开输出:现状存在
  「missing_terminal=true 而记账层判 Success」的组合(report kind 不要求观察到
  终态事件时),会写出 status=Success + error_type=stream_missing_terminal_event,
  这个组合必须原样保留。

classify_responses_websocket_turn_effect 的判定表原样搬入 settlement.rs,分支
和顺序均未改动,两个既有不变量测试随之迁移。

行为等价。结算表当前口径与拆分前完全一致:客户端投递失败仍与「供应商声明取消」
落在同一侧(作废账单、candidate 记 Cancelled、只释放 lease、不提交 execution
report),即使 provider 终态已经到达——这一行由
settlement_table_row_client_delivery_failure_currently_voids_a_reached_terminal
锁住现状,修正它是下一步独立的行为修正。

新增 15 个测试:outcome → 双事实映射表逐行(含 stream_timeout 只在 504 失败一族
成立、provider 终态即使 504 也不投射流式超时)、结算表逐行、投递失败时
forced_error 必须为 None、已观察终态不被 Cancelled 覆盖、以及跨整张表的
「每个分支都释放 pool key lease」「作废账单一律不提交 report」不变量。
2026-08-17 14:53:05 +08:00
AAEE86 1c5ee5228c refactor(ws): 用 ResponsesTurnState 收敛连接 turn 状态
评审第 2 条:BoundResponsesConnection 用 response_in_flight、active_turn、
active_response_create 三个可独立变化的字段编码同一件事,8 种组合里只有 3 种
合法,非法组合只能靠调用点的 if 和「记得同时改另外两个字段」来避免。

三字段合并为一个 ResponsesTurnState:

  Idle                                  没有进行中的 logical turn
  Responding { logical, attempt }        logical 与 attempt 必须同时存在
  Replanning { logical }                 attempt 已取走去结算/重绑,logical 仍在

Replanning 不是新概念:配额透明重试期间现状就处于这个状态,只是靠
Option::take 意外得到。转换只能走 begin / detach_attempt / resume / end,
response_in_flight 与「是否接受新 response.create」都由变体推导。

由此消除的运行时不变量(原来全靠调用点自觉):
- 有 attempt 必有 logical turn
- response_in_flight 与 attempt 同生共死(原来 client 写失败后
  active_turn=None 而 response_in_flight 仍为 true)
- logical turn 结束时必须清 attempt:原来 `active_response_create = None`
  在 connection.rs 里手写 13 处,漏一处就残留;现在只有 end() 一个出口
- 上游绑定返回的连接不再自带 response_in_flight=true 的半成品状态

同时删除 update_response_in_flight:Started 帧把已经是 true 的字段再设一次,
Close 帧因为没有解析出的 frame 而根本不触发,是纯冗余写;它在 Idle 态收到
Started 帧时还会把 response_in_flight 置真,从而永久阻塞后续 response.create。

行为等价。ActiveResponsesWebSocketRequest 改名 LogicalTurn 并随状态机移入
新的 turn_state.rs;状态机对 attempt 类型泛型化,测试用轻量替身驱动同一套
转换逻辑,无需 AppState 或真实 socket。
2026-08-17 14:52:57 +08:00
AAEE86 9d80281b53 fix(ws): route WS planning and continuation through PII redaction 2026-08-17 14:52:49 +08:00
AAEE86 3b036299d4 fix(ws): enforce absolute upstream handshake and initial-message deadlines 2026-08-17 14:52:39 +08:00
AAEE86 f70ae68273 fix(ws): treat max_output_tokens incomplete as legitimate terminal 2026-08-17 14:52:33 +08:00
AAEE86 1353d76e07 feat(frontend): Responses WebSocket 配置与用量展示
provider 表单支持开启 Responses WebSocket;用量列表、状态与详情
区分 WebSocket 请求。
2026-08-17 14:52:25 +08:00
AAEE86 621a528083 test(ws): Responses WebSocket 端到端套件接入 CI
补齐 aether-integration-tests 的 responses_websocket_e2e 集成测试,
并把 CI 的 scenario 任务从 --bins 改为 --bins --tests,否则该套件
不会被执行。
2026-08-17 14:51:24 +08:00
AAEE86 a498875591 feat(gateway): Responses WebSocket 连通性探针
新增 aether-codex-ws-probe 与 aether-openai-responses-ws-probe 两个
二进制,用于在不暴露凭据的前提下验证上游 WebSocket 端点可用性:凭据
只从环境变量读取,不写入日志。公共流程放在
bin/support/responses_ws_probe.rs,各 profile 只负责自己的鉴权与
请求头要求。
2026-08-17 14:51:18 +08:00
AAEE86 71b54070e8 feat(gateway): Codex/OpenAI Responses WebSocket 代理模式
在 /v1/responses 上支持 WebSocket 升级,把客户端帧中继到上游 Codex /
OpenAI Responses WebSocket 端点,同时保持既有的路由、鉴权、配额与用量
语义:

- 路由与准入:control/route/ai.rs 识别 WebSocket 升级请求;
  websocket/ingress.rs 复用 API Key 鉴权、IP 规则与并发许可,并引入
  独立的 WebSocket 连接许可
- 中继:websocket/responses/* 按 connection / session / turn 分层,
  帧解析归一化、socket 写入有界、continuation 保持调度亲和性
- 配额:orchestration/codex_quota_breaker.rs 在账号配额耗尽时熔断并
  自动恢复,不再直接断开客户端连接
- 用量:每个 turn 的终态用量落库,request_metadata 记录
  websocket_mode / websocket_transport,管理端与 usage 视图暴露
  is_websocket
- 管理端:provider 可配置 Responses WebSocket 开关
2026-08-17 14:50:33 +08:00
ZheFox 9a0d346ff3 Merge pull request #728 from zhefox/main
fix(gateway): stop candidate persistence retry storms
2026-08-17 14:28:11 +08:00
ZheFox 32944538e9 fix(gateway): stop candidate persistence retry storms 2026-08-17 13:49:12 +08:00
ZheFox 0b17026eab Merge pull request #726 from zhefox/main
fix(codex): restore upstream model discovery
2026-08-15 20:16:12 +08:00
ZheFox b13d9b9b40 fix(codex): restore upstream model discovery 2026-08-15 19:36:28 +08:00
ZheFox b7fca851b8 Merge pull request #724 from zhefox/main
fix(codex): serve versioned dynamic model catalogs
2026-08-14 19:08:53 +08:00
zhefox 810c3dfe2b fix(codex): serve versioned dynamic model catalogs 2026-08-14 18:41:44 +08:00
elky a1d64e5239 fix(routing): preserve allowlist edits and save state 2026-08-14 11:43:24 +08:00
elky fb33ea57b0 Merge PR #715: decouple routing model overrides 2026-08-14 11:10:21 +08:00
elky 5b0c763086 fix(codex): fence concurrent quota updates 2026-08-14 09:28:07 +08:00
elky f3a12c1008 fix(ai): preserve Codex image edit validation 2026-08-13 11:31:17 +08:00
elky ca35e09eaa Merge pull request #718 from zjm54321/fix/custom-image-edit-json 2026-08-13 11:00:15 +08:00
elky 8cf381b0c3 feat(codex): add OAuth fingerprint convergence 2026-08-13 09:57:17 +08:00
elky 654c4f6978 fix(auth): preserve turnstile while typing email 2026-08-12 16:56:18 +08:00
elky edb8362adc fix(provider): omit default model test temperature 2026-08-12 16:56:18 +08:00
elky 29fa4aed19 perf(gateway): raise default server pool floor 2026-08-12 16:56:18 +08:00
zjm54321 41e93858e1 refactor(ai): simplify image edit serialization 2026-08-11 00:36:33 +08:00
zjm54321 8d918d0459 fix(ai): serialize image edits with images array 2026-08-11 00:30:00 +08:00
ZheFox 3a759fae89 Merge pull request #712 from zhefox/fix/claude-code-conversion-api-key-toggle
fix: restore Claude Code conversion and user API key toggles
2026-08-05 14:53:03 +08:00
zhefox 985ff3c36a test(gateway): align claude_code endpoint reconciliation 2026-08-05 14:34:20 +08:00
zhefox 908d4f2603 style(provider): apply rustfmt to claude_code tests 2026-08-05 13:58:46 +08:00
zhefox 4d67569873 fix(gateway): support claude_code cross-format Claude messages 2026-08-05 13:57:20 +08:00
ZheFox 1aab31a148 Merge pull request #710 from zhefox/main
fix: align Responses compatibility, routing, and model permissions
2026-08-03 19:31:04 +08:00
zhefox aedff9a704 fix(provider): validate mapped model reasoning effort 2026-08-03 19:22:51 +08:00
zhefox 669f4bddc5 fix: align Responses routing and model permissions 2026-08-03 18:48:01 +08:00
zbs 1a4eede34d fix(routing): decouple model overrides from allowed scope 2026-08-03 07:52:49 +08:00
elky 0318808db9 fix(providers): allow transfer limits on creation 2026-07-31 13:35:08 +08:00
elky 06f5d3c8c0 fix(gateway): complete worker registration cleanup 2026-07-31 11:32:07 +08:00
elky 082407fa51 Merge PR #697: prevent duplicate worker registrations 2026-07-31 11:11:14 +08:00
fawney19 6688ee26db Merge pull request #702 from MMEXA/fix/reconcile-auth-channel-mismatch-formats
fix(gateway): 修复批量更新 API 格式时的认证通道状态冲突
2026-07-31 10:28:37 +08:00
elky beb003b7ad feat(models): add external catalog proxy selection 2026-07-31 09:32:25 +08:00
MMEXA 6ecfe0f0a1 fix(gateway): reconcile auth mismatch formats on key update 2026-07-30 22:14:08 +08:00
ZheFox 12057db476 Merge pull request #701 from zhefox/main
Persist OpenAI Responses continuation history across instances
2026-07-30 21:08:34 +08:00
ZheFox ff47d8d48a fix(gateway): route response history through ai seam 2026-07-30 20:34:41 +08:00
ZheFox ef5f36cc2b fix(ai): satisfy response history clippy checks 2026-07-30 20:13:36 +08:00
ZheFox 84022c4d48 Merge upstream/main into main 2026-07-30 19:40:39 +08:00
ZheFox 118f441029 feat(gateway): persist OpenAI Responses continuation history 2026-07-30 19:26:52 +08:00
elky 20399b004d Merge PR #700: fix admin pool batch update body buffering
Preserve main's failover and usage metadata fixes, restore default tunnel regression coverage, and satisfy current Clippy.
2026-07-30 17:56:37 +08:00
elky 050eb77508 fix(ai): harden responses replay and failure diagnostics 2026-07-30 17:19:54 +08:00
elky 1ab4f079c9 fix(gateway): restore failover and usage diagnostics 2026-07-30 09:12:11 +08:00
MMEXA 6c733f7590 fix(usage): preserve request diagnostics in event seeds 2026-07-30 06:44:59 +08:00
MMEXA d7d8db45ba test(gateway): align tunnel error fixture with failover policy 2026-07-30 06:44:59 +08:00
MMEXA 8cf9af79da fix(ci): remove redundant usage policy update 2026-07-30 05:45:38 +08:00
MMEXA e55793c765 fix(ci): satisfy gateway clippy on upstream baseline 2026-07-30 05:14:34 +08:00
MMEXA d8902ea612 fix(gateway): buffer admin pool batch update bodies 2026-07-30 05:14:34 +08:00
elky a04673a90d feat(gateway): harden failover and payload handling
Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics.

Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
2026-07-30 01:03:27 +08:00
ZheFox a97acc07fc Merge pull request #698 from zhefox/main
fix(ci): stabilize cross-platform workflow checks
2026-07-29 22:16:17 +08:00
zhefox f8000012f7 fix(ci): stabilize cross-platform workflow checks 2026-07-29 21:55:43 +08:00
worker-2 6080f8cc88 fix(gateway): stabilize worker task records
Key worker boot records by task so process restarts update the existing
row instead of registering another row for each gateway instance.

Closes #693
Confidence: high
Scope-risk: narrow
2026-07-29 17:27:51 +08:00
ZheFox 37df5b93b1 Merge pull request #696 from zhefox/main
Fix client metadata handling across formats
2026-07-28 18:12:59 +08:00
ZheFox e53abdaec2 Merge branch 'fawney19:main' into main 2026-07-28 18:12:28 +08:00
ZheFox 2db32ea97e Merge branch 'main' of https://github.com/zhefox/Aether 2026-07-28 17:40:46 +08:00
ZheFox 581897ee74 fix(formats): ignore responses client metadata across targets 2026-07-28 17:40:41 +08:00
ZheFox 9a88f966d8 Merge pull request #695 from zhefox/main
Enhance provider capabilities and clean up OAuth keys
2026-07-28 13:58:33 +08:00
ZheFox 9d9316e434 Merge branch 'fawney19:main' into main 2026-07-28 13:56:34 +08:00
ZheFox 1b697b1111 feat(providers): support FedRAMP Codex agent identity registration 2026-07-28 13:29:30 +08:00
ZheFox 3043982486 fix(providers): derive Codex primary quota label from window 2026-07-28 12:57:45 +08:00
ZheFox 0bf92ffffc feat(providers): advertise responses API agent capability 2026-07-28 12:02:03 +08:00
ZheFox f0f87b56a3 feat(providers): add credential-fenced OAuth key cleanup 2026-07-28 11:32:11 +08:00
elky 4148ab1931 fix(routing): harden routed pool scheduling 2026-07-27 22:06:28 +08:00
elky 550cc36760 feat(providers): expand OAuth account management
Add Claude Code manual and cookie authorization, including redacted batch tasks. Harden OAuth imports, duplicate replacement, provider dialogs, and related account-management tests.
2026-07-27 15:53:28 +08:00
elky 531cf11025 feat(gateway): harden provider request execution
Preserve exact request payloads and model client surface and API operation explicitly.

Add Anthropic compatibility profiles, bounded stream commitment, and scoped OAuth retry behavior across provider transports.
2026-07-27 09:36:31 +08:00
elky 79b70f7b5c fix(frontend): align sidebar collapse button 2026-07-26 15:07:51 +08:00
elky 10d369f59c feat(providers): add provider transfer limits 2026-07-26 15:06:56 +08:00
elky 2ef7ac79bc feat(frontend): add collapsible navigation sidebar
Persist the desktop sidebar state, provide accessible compact navigation tooltips, and cover the collapsed navigation markup with a focused component test.
2026-07-25 21:28:51 +08:00
elky 778cfb1a5c feat(data): complete portable SQL backend parity
Align MySQL and SQLite schemas, migrations, usage, stats, export, and backfill behavior with the shared data contracts. Extend gateway startup and maintenance support across all SQL drivers.
2026-07-25 21:28:21 +08:00
elky 764e9fd131 feat(frontend): improve provider detail drawer and pool actions 2026-07-25 11:16:59 +08:00
elky 387134ca87 fix(models): correct fast pricing and online sync 2026-07-24 01:45:38 +08:00
elky a0767d957c fix(frontend): synchronize pool account state 2026-07-23 16:20:18 +08:00
ZheFox b94ef91d07 Merge pull request #692 from zhefox/main
Sync global model prices and track online pricing sources
2026-07-23 16:02:17 +08:00
ZheFox e7910751d9 Merge branch 'fawney19:main' into main 2026-07-23 15:19:48 +08:00
ZheFox 1d2655432d feat(models): track online pricing sources and unsupported fields 2026-07-23 15:18:08 +08:00
ZheFox 323273ff30 feat(models): sync global model prices from online catalog 2026-07-23 13:29:28 +08:00
ZheFox fb2009c65b Merge pull request #691 from zhefox/main
fix(formats): ignore Responses client transport metadata
2026-07-23 12:18:40 +08:00
ZheFox e186cc6848 Merge branch 'main' of https://github.com/zhefox/Aether 2026-07-23 12:17:46 +08:00
ZheFox 615ac99ad7 fix(formats): ignore Responses client transport metadata 2026-07-23 12:16:44 +08:00
ZheFox ec36cfbf75 Merge pull request #690 from zhefox/main
fix(provider): classify deleted Codex agent runtime as invalid
2026-07-23 11:22:30 +08:00
ZheFox 7bf228a33c fix(provider): classify deleted Codex agent runtime as invalid 2026-07-23 11:21:55 +08:00
elky 3606290ac8 fix(provider): harden Agent Identity OAuth lifecycle 2026-07-23 09:33:00 +08:00
elky e49024d33b fix(frontend): shorten Agent Identity tab label 2026-07-22 20:26:49 +08:00
elky fdbc2607ec feat(provider): add dedicated Codex Agent Identity flow 2026-07-22 20:19:29 +08:00
elky c7cc8fd7db test(provider): simplify agent identity assertions 2026-07-22 14:19:58 +08:00
elky 07efcb5146 fix(data): repair legacy active flag synchronization 2026-07-22 14:19:34 +08:00
elky 856605defa fix(model-directives): harden suffix configuration 2026-07-22 14:19:09 +08:00
elky 713010fa0a fix(gateway): restore auth role refresh and Rust checks
Refresh the resolved user role without bypassing owner group and key policies. Resolve Rust 1.95 Clippy failures and make the pending persistence bound test scheduler-independent.
2026-07-22 11:25:24 +08:00
ZheFox cd2fbeeead Merge pull request #689 from AAEE86/feat/agent-identity-support
feat(codex): enroll agent identity from session token
2026-07-22 10:32:06 +08:00
AAEE86 a4350a482a feat(codex): enroll agent identity from session token 2026-07-22 10:21:11 +08:00
ZheFox c825375367 Merge pull request #688 from AAEE86/feat/agent-identity-support
feat(codex): support agent identity accounts
2026-07-22 09:20:11 +08:00
elky fc92c4f431 perf(gateway): scale request hot paths for 20k streams
Shard and singleflight hot-path caches, batch and prioritize candidate and usage lifecycle persistence, and extend database and pressure-test instrumentation for 20k concurrent streams.
2026-07-22 02:11:08 +08:00
AAEE86 b61c590bdb feat(codex): support agent identity accounts 2026-07-21 20:58:49 +08:00
ZheFox 7756c0913f Merge pull request #685 from zhefox/main
fix(gateway): apply group policy to admin-owned keys
2026-07-20 15:52:06 +08:00
ZheFox c34ec7c1ee fix(gateway): apply group policy to admin-owned keys 2026-07-20 15:51:01 +08:00
elky f8778c4a23 feat(gateway): configure cyber policy failover 2026-07-19 23:27:19 +08:00
elky e0dbb233f7 fix(frontend): avoid misleading cache TTL fallback label 2026-07-19 22:21:46 +08:00
elky 9725f9abae fix(frontend): clarify processing tier pricing 2026-07-19 22:00:12 +08:00
elky 5d575f1590 test(stats): treat bulk API key snapshots as authoritative 2026-07-19 19:07:04 +08:00
elky d562c594c3 fix(frontend): preserve compact scope and detail badge 2026-07-19 16:42:32 +08:00
MMEXA ce226a3010 Merge 0c3f51bcec into 644ae9c1bf 2026-07-19 16:12:37 +08:00
elky 644ae9c1bf feat(pool): add table-driven account batch actions 2026-07-19 16:09:20 +08:00
elky 95053f9502 Merge PR #672: 支持账号批量配置与可用模型管理 2026-07-18 22:06:32 +08:00
elky 8fbda84acb fix(data): preserve API key history end to end 2026-07-18 21:58:21 +08:00
elky 03b7d573e0 Merge PR #683: decouple API key historical identity 2026-07-18 21:20:35 +08:00
MMEXA 0c3f51bcec merge(main): 解决 usage 模型展示契约冲突 2026-07-18 19:26:14 +08:00
elky e3d97b573b fix(usage): align fast-tier pricing and model metadata 2026-07-18 16:57:04 +08:00
MMEXA ac3796af84 fix(gateway): 恢复响应边界并统一格式入口 2026-07-18 06:45:37 +08:00
MMEXA f9c343eb07 fix(gateway): 适配 Rust 1.95 整除检查 2026-07-18 05:55:06 +08:00
MMEXA e31df5989a merge(main): 解决 usage 展示与生命周期同步冲突 2026-07-18 05:38:38 +08:00
MMEXA 98fbf029fc fix(data): 解耦 API Key 历史统计身份 2026-07-18 04:42:45 +08:00
MMEXA 4d9a648202 test(gateway): 统一流错误测试的格式层入口 2026-07-18 03:37:31 +08:00
MMEXA 405ca3e66a fix(ci): 恢复非流式错误体边界并适配新版 Clippy 2026-07-18 03:26:53 +08:00
MMEXA 0355c28683 fix(data): 解耦候选记录的 API Key 历史身份 2026-07-18 02:40:34 +08:00
fawney19 6c33b8d8fb Merge pull request #682 from MMEXA/codex/codex-prompt-cache-identity-20260717
fix(codex): 统一通用缓存键与原生会话身份
2026-07-18 00:33:20 +08:00
elky a6c6f14b09 style(frontend): align pool cycle stats values 2026-07-18 00:13:35 +08:00
elky e558f55cd9 style(frontend): refine badges and cycle stats 2026-07-18 00:05:22 +08:00
elky 88a057b8d9 fix(usage): force fast badge background transparent 2026-07-17 22:56:54 +08:00
elky ed27d404ac style(usage): make fast badge background transparent 2026-07-17 22:52:47 +08:00
elky 5dda34c66e style(usage): give fast tier an amber accent 2026-07-17 22:36:55 +08:00
elky 373ebf26d6 fix(pricing): default zero tier ratios to one 2026-07-17 21:33:21 +08:00
elky f65ed2795c fix(codex): support dynamic quota windows 2026-07-17 20:18:04 +08:00
elky 664c063a06 feat(usage): enrich audit metadata and detail views 2026-07-17 19:20:16 +08:00
MMEXA 75795c6fbc test(codex): 对齐 Compact 确定性缓存身份 2026-07-17 08:49:35 +08:00
MMEXA 5b332da7d7 fix(codex): 补齐缓存身份终态请求头 2026-07-17 08:11:16 +08:00
MMEXA d9796d502b fix(codex): 统一通用缓存键与原生会话身份 2026-07-17 06:13:09 +08:00
MMEXA 3b0d87b0fd Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714 2026-07-17 00:17:02 +08:00
MMEXA 3c348dff3a Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts:
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
2026-07-17 00:16:58 +08:00
MMEXA ec1783a35c Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
# Conflicts:
#	apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs
#	frontend/src/api/endpoints/pool.ts
2026-07-16 23:43:04 +08:00
MMEXA 427030c5de Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts:
#	crates/aether-ai-formats/src/formats/openai/responses/mod.rs
#	crates/aether-usage/runtime/src/runtime.rs
#	frontend/src/features/usage/components/UsageRecordsTable.vue
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
2026-07-16 23:41:58 +08:00
elky 0be380243b feat(pricing): support processing tier multipliers 2026-07-16 23:30:42 +08:00
fawney19 312583f055 Merge pull request #680 from Kayphoon/codex/s3-backup-user-agent
feat(admin): configure S3 backup User-Agent
2026-07-16 23:30:30 +08:00
fawney19 33f49ea9b0 Merge pull request #678 from AAEE86/fix
fix: map Developer role to "system" in OpenAI Chat Completions output
2026-07-16 23:29:55 +08:00
fawney19 470cef17cf Merge pull request #676 from MMEXA/codex/sync-capture-envelope-finalize-20260716
修复同步 finalize 的 Responses 流聚合与转换
2026-07-16 23:29:38 +08:00
ZheFox 3f5f65eb9a Merge pull request #681 from zhefox/main
Codex 重置功能和显示缓存修复以及批量key的导入和管理功能
2026-07-16 19:48:33 +08:00
ZheFox 6664c2dbb8 feat(pool): 支持批量导入 Key 和选择性更新设置 2026-07-16 19:31:07 +08:00
ZheFox 0099167a6d fix(codex): 避免重置机会缺失触发配额刷新 2026-07-16 19:13:09 +08:00
ZheFox f009fb73c3 缓存问题修复 2026-07-16 18:55:28 +08:00
ZheFox 715a5ed626 修复重置次数缓存问题 2026-07-16 18:09:10 +08:00
ZheFox 5cf38d1b35 Codex 重置功能和显示修复 2026-07-16 17:23:41 +08:00
Kayphoon 6b707f29a2 feat(admin): configure S3 backup User-Agent 2026-07-16 08:56:56 +00:00
elky 9ea84f9748 fix(frontend): show service tier transitions 2026-07-16 16:38:30 +08:00
elky c32d043afb fix(frontend): preserve fetched model preset pricing 2026-07-16 16:38:30 +08:00
elky 8fe4d24408 fix(usage): canonicalize cached token totals 2026-07-16 16:38:30 +08:00
elky e369e4aab1 fix(formats): preserve chat-backed Responses metadata 2026-07-16 16:38:30 +08:00
ZheFox 7dc919e8e3 Merge pull request #679 from zhefox/main
fix(frontend): 优化移动端弹窗并完善提供商配额刷新
2026-07-16 15:48:49 +08:00
ZheFox 1333efdad5 fix(frontend): 优化移动端弹窗并完善提供商配额刷新 2026-07-16 15:21:11 +08:00
AAEE86 cd8de1aa13 fix: map Developer role to "system" in OpenAI Chat Completions output 2026-07-16 14:29:08 +08:00
elky d6215d9dec ci(tunnel): reduce artifact retention 2026-07-16 13:12:30 +08:00
elky 9a47267545 fix(usage): bound terminal event persistence
Add end-to-end terminal admission, bounded database fallback, and observable overload handling. Preserve first-byte lifecycle state across asynchronous runtime and frontend updates.
2026-07-16 13:12:30 +08:00
MMEXA 7851503fbc fix(finalize): 严格聚合并投影同步 Responses 流 2026-07-16 12:50:00 +08:00
ZheFox c6d373e6aa Merge pull request #677 from zhefox/main
fix(codex): 移除 Responses Lite 请求中的 context_management
2026-07-16 12:28:34 +08:00
ZheFox 71fcb9c168 fix(codex): 服务端压缩使用标准 Responses 合约 2026-07-16 12:02:33 +08:00
ZheFox 3976652942 fix(codex): 移除 Responses Lite 请求中的 context_management 2026-07-16 11:25:11 +08:00
MMEXA 7b56546e21 fix(finalize): 聚合同步流捕获包装 2026-07-16 10:24:32 +08:00
fawney19 85854e4476 Merge pull request #675 from fawney19/fix/pr-669-tail
feat(codex): complete PR #669 protocol follow-up
2026-07-16 08:54:29 +08:00
elky b50242ab9f fix(test): handle absent empty testkit bin directory 2026-07-16 01:29:51 +08:00
MMEXA 20b27a13b2 feat(codex): 按操作语义路由 Responses V2 压缩
(cherry picked from commit 2fc604e047)
2026-07-16 00:34:42 +08:00
MMEXA 598b2fb374 fix(auth): 授权 Responses Compact 伴随端点
(cherry picked from commit e8afa03e45)
2026-07-16 00:32:14 +08:00
MMEXA ff7988430d fix(openai): encode tool errors in Responses output
(cherry picked from commit f127b67e73)
2026-07-16 00:31:23 +08:00
MMEXA 25da99fac2 fix(codex): preserve reset consume request body
(cherry picked from commit fc2dfb82d2)
2026-07-16 00:27:28 +08:00
elky 8616fe6ee2 refactor(workspace): enforce layered crate boundaries 2026-07-15 23:47:19 +08:00
MMEXA 9b8724453b test(pool): 使用正式 Gemini API 格式 2026-07-14 08:39:41 +08:00
MMEXA 01e104d86a fix(pool): 对齐账号批量配置语义 2026-07-14 08:07:28 +08:00
MMEXA 0acd1de29c fix(gateway): 保持密钥更新模块显式所有权 2026-07-14 05:19:04 +08:00
MMEXA a25fab371a feat(pool): add bulk key configuration management 2026-07-14 04:57:05 +08:00
MMEXA 93e2f95c47 fix(frontend): 按端点能力约束会话压缩映射 2026-07-14 02:05:10 +08:00
MMEXA f10d631a9c feat(frontend): 澄清模型映射适用范围 2026-07-14 00:30:39 +08:00
MMEXA cfc4894dab fix(usage): 保留最新进行态生命周期事件 2026-07-14 00:30:24 +08:00
MMEXA 3f86fdd6bc feat(usage): 展示压缩操作与进行态请求语义 2026-07-13 22:03:44 +08:00
MMEXA b09d1f1c33 fix(usage): expose pending reasoning and exact reset expiry 2026-07-13 22:03:44 +08:00
MMEXA 2fc604e047 feat(codex): 按操作语义路由 Responses V2 压缩 2026-07-13 22:03:34 +08:00
MMEXA e8afa03e45 fix(auth): 授权 Responses Compact 伴随端点 2026-07-13 06:07:54 +08:00
MMEXA fc2dfb82d2 fix(codex): preserve reset consume request body 2026-07-12 23:04:33 +08:00
elky a728c090a9 fix(gateway): scope concurrency helper to tests 2026-07-12 22:36:48 +08:00
elky e58621a735 Merge PR #669: align GPT-5.6 and Codex request protocols 2026-07-12 21:50:20 +08:00
MMEXA b1be370b2e fix(gateway): scope concurrency test helper to tests 2026-07-12 21:08:30 +08:00
MMEXA cf0d957ac7 Merge f127b67e73 into 7f61bb43c7 2026-07-12 20:34:39 +08:00
MMEXA f127b67e73 fix(openai): encode tool errors in Responses output 2026-07-12 20:34:31 +08:00
elky 7f61bb43c7 feat(security): harden gateway request and runtime controls 2026-07-12 14:10:54 +08:00
MMEXA 25c49dd804 fix(data): keep terminal usage state monotonic 2026-07-12 05:45:37 +08:00
MMEXA 72222d935c test(gateway): use valid tunnel relay envelopes 2026-07-12 05:45:32 +08:00
MMEXA 063d517306 test(gateway): compare timeout response numerically 2026-07-12 04:43:13 +08:00
MMEXA 02495ce28e fix(admin): preserve inactive endpoint key counts 2026-07-12 04:19:06 +08:00
MMEXA 63936aa110 fix(gateway): route format rules through serving facade 2026-07-12 03:56:53 +08:00
MMEXA 8d4d42a887 fix(auth): resolve group policy before key intersection 2026-07-12 03:35:42 +08:00
MMEXA 2316df5c9a feat(codex): align Search and execution protocol 2026-07-12 03:04:15 +08:00
MMEXA 59d37ae1dd fix(frontend): import structured models.dev pricing 2026-07-11 18:12:55 +08:00
MMEXA 3014fd50c6 fix(billing): preserve effective cache and tier facts 2026-07-11 18:12:55 +08:00
MMEXA 14c4e3a04e fix(codex): enforce provider request identity 2026-07-11 18:09:14 +08:00
MMEXA 8f1070a451 feat(frontend): expose processing tier pricing 2026-07-11 12:27:09 +08:00
MMEXA 0b30cc6b0f feat(openai): unify tier authorization and settlement 2026-07-11 12:27:05 +08:00
MMEXA b2f596b8f0 fix(gateway): route Codex header through serving facade 2026-07-11 09:43:12 +08:00
MMEXA 01a96fed74 fix(codex): simplify summary normalization 2026-07-11 09:20:07 +08:00
MMEXA 46a903aada fix(codex): align current reasoning request semantics 2026-07-11 09:15:08 +08:00
MMEXA dfa121dd5b feat(openai): align GPT-5.6 and Codex request contracts 2026-07-11 07:40:12 +08:00
elky bc1da3bf3f feat(security): harden client IP and admin controls 2026-07-10 15:13:12 +08:00
elky 6e0dc3b59e feat(frontend): refine global model pricing dialog 2026-07-10 15:13:12 +08:00
elky 4bf5d4c044 Fix cache token accounting and tiered pricing 2026-07-10 15:13:12 +08:00
fawney19 736fc76345 Merge pull request #668 from MMEXA/codex/antigravity-empty-output-retry-20260709
修复 Gemini 空输出按候选重试处理
2026-07-10 09:16:56 +08:00
MMEXA b6b2ca38f4 触发 CI 重跑 2026-07-10 00:41:22 +08:00
MMEXA f07eb25cfc 修复 usage 详情 body 引用解包 2026-07-10 00:23:31 +08:00
MMEXA d2ea437c1c 修复 Gemini 空输出按候选重试处理 2026-07-09 23:10:30 +08:00
fawney19 7bc7d0f8d8 Merge pull request #666 from xixiknow/main
Fix provider key response time counter overflow
2026-07-09 18:04:11 +08:00
fawney19 14cf639aba Merge pull request #667 from MMEXA/codex/antigravity-v1internal-query-20260709
修复 Antigravity v1internal 查询参数透传
2026-07-09 17:50:38 +08:00
yangrs 55cdab592c Remove redundant response time conversion 2026-07-09 16:26:09 +08:00
MMEXA ee0ec18283 修复 Antigravity v1internal 查询参数透传 2026-07-09 16:03:29 +08:00
Start f31c9e03e2 Merge branch 'fawney19:main' into main 2026-07-09 15:11:44 +08:00
fawney19 e50db10439 Merge pull request #663 from MMEXA/codex/gemini-interactions-antigravity-20260705
完善 Gemini Interactions 与 Antigravity 全链路兼容
2026-07-09 14:55:51 +08:00
yangrs 192dc6c20d Fix provider key response time overflow 2026-07-09 14:40:48 +08:00
elky 5e1d14f19b Fix timeline duration display from latency 2026-07-09 11:46:45 +08:00
MMEXA b8b89d21b7 fix: 同步提交本地 sync 错误上报 2026-07-08 23:49:18 +08:00
MMEXA 5eddf4f9ee 细化 Antigravity 测试模型项目元数据补全 2026-07-08 22:45:30 +08:00
MMEXA c7186e1720 完善 Antigravity 配额展示与 CI 断言 2026-07-08 22:34:29 +08:00
MMEXA 4866509938 移除 Antigravity 未知重置时间噪音 2026-07-08 22:34:29 +08:00
MMEXA 2122660a5c 对齐原生 Antigravity 控制面与显示模型 2026-07-08 22:34:29 +08:00
MMEXA 5c68ab896a 恢复历史 backfill 兼容 live 账本 2026-07-08 22:34:29 +08:00
MMEXA f9c8ec41f4 完善 Antigravity 与 Gemini 跨格式兼容 2026-07-08 22:34:29 +08:00
MMEXA b1ed6b24b0 触发 CI 复跑 2026-07-08 22:34:29 +08:00
MMEXA c17c78ad4b 修正 Antigravity Gemini 3.5 Flash 档位展示 2026-07-08 22:34:29 +08:00
MMEXA 9ec48ab6b9 优化 Antigravity 配额展示顺序 2026-07-08 22:34:29 +08:00
MMEXA accd250226 修正 Antigravity 配额模型标签 2026-07-08 22:34:29 +08:00
MMEXA 80a6579766 支持 Gemini Interactions 与 Antigravity 配额精细化 2026-07-08 22:34:29 +08:00
fawney19 1ca83ca3fb Merge pull request #664 from MMEXA/codex/wallet-auth-cache-delay-20260706
修复钱包余额变更后的鉴权缓存延迟
2026-07-07 01:59:44 +08:00
fawney19 a931da0764 Merge pull request #662 from MMEXA/codex/reset-credit-20260704
增加 Codex 重置次数功能
2026-07-07 01:58:44 +08:00
fawney19 a61374c595 Merge pull request #661 from MMEXA/codex/frontend-debug-20260704
修复前端调试与基础交互问题
2026-07-07 01:57:41 +08:00
MMEXA c3136126e5 修复钱包余额变更后的鉴权缓存延迟 2026-07-06 06:15:31 +08:00
MMEXA b23d299533 重跑 Codex 重置次数 CI 2026-07-05 01:37:31 +08:00
MMEXA b03aae18c3 修复 Codex 重置次数 CI 检查 2026-07-04 15:47:01 +08:00
MMEXA 99b6fe468f 简化 Codex 重置机会展示标签 2026-07-04 15:16:38 +08:00
MMEXA ef77ec04ca 增加 Codex 重置次数功能 2026-07-04 06:10:53 +08:00
MMEXA 242081433e 修复前端调试与基础交互问题 2026-07-04 05:24:40 +08:00
ZheFox b86d4e1f0c Merge pull request #660 from zhefox/main
refactor(frontend): unify mobile menu background styles
2026-07-03 13:17:59 +08:00
ZheFox a151f37d63 refactor(frontend): unify mobile menu background styles 2026-07-03 13:17:18 +08:00
ZheFox 42f7907740 Merge pull request #659 from zhefox/main
refactor(frontend): improve mobile overflow handling
2026-07-03 12:54:01 +08:00
ZheFox e72e25c59c refactor(frontend): improve mobile overflow handling 2026-07-03 12:53:22 +08:00
ZheFox 1b0440481b Merge pull request #658 from zhefox/main
修复管理端额度显示、节点表格显示与移动端滚动问题
2026-07-03 12:49:15 +08:00
ZheFox 26d85681f0 refactor(frontend): improve mobile overflow and proxy node table 2026-07-03 12:25:53 +08:00
ZheFox 1dcee77055 refactor(frontend): improve dialog and mobile overflow handling 2026-07-03 11:26:40 +08:00
elky 1ac16005f9 Stabilize usage worker autoscale tests 2026-07-02 17:28:25 +08:00
elky 2f1cdb6a0b Record exhausted usage failures synchronously 2026-07-02 16:08:04 +08:00
elky ac93851b2a Stabilize Gateway h2c transport test 2026-07-02 14:02:26 +08:00
elky 400b3125a4 Preserve terminal request candidate state 2026-07-02 01:40:57 +08:00
elky 2e5ff32e1a perf(frontend): 收敛导航预取并去重首屏请求
- 导航预取仅保留 pointerdown 触发,移除 mouseenter/focus,避免鼠标划过误触发
- 后台预取只做组件懒加载,不再预取各页业务数据,减少首屏资源争抢
- 版本状态检查增加 sessionStorage 缓存(正常 20 分钟 / 错误 5 分钟 TTL)
- fetchModules、必读公告拉取增加请求去重,避免并发重复请求
- 更新检查改用可清理的定时器,组件卸载时清理
- UsageRecordsTable 搜索防抖改为自定义实现,卸载时取消挂起 emit 并补充测试
2026-07-01 20:42:52 +08:00
elky a0f7074e59 chore: disable Redis persistence by default, document triage and policy 2026-07-01 14:15:16 +08:00
elky 7c32be46ca Mark sync usage active earlier 2026-07-01 02:21:20 +08:00
Entropy.Xu 6ed2f9bd0a fix: apply actual billing cost to wallet settlement 2026-07-01 01:12:40 +08:00
elky 778b106023 test: stabilize gateway nextest timing 2026-06-30 18:42:57 +08:00
elky f179ee72f9 chore: update gateway pressure observability 2026-06-30 17:01:39 +08:00
elky 974def5fef refactor(frontend): extract provider key identity block 2026-06-30 17:01:39 +08:00
elky e5351b7d9d refactor(frontend): extract provider key actions 2026-06-30 17:01:39 +08:00
elky ed83184d55 refactor(frontend): extract provider quota display components 2026-06-30 17:01:39 +08:00
elky 15b6606c82 refactor(frontend): extract pool key display panels 2026-06-30 17:01:39 +08:00
elky d7411a3104 refactor(frontend): extract pool header and theme toggle 2026-06-30 17:01:39 +08:00
elky 9f138d09e6 refactor(frontend): modularize i18n architecture 2026-06-30 17:01:39 +08:00
ZheFox bf29129a4b Merge pull request #654 from zhefox/main
Cancel upstream streams on client disconnect and void cancelled usage billing
2026-06-29 01:14:11 +08:00
zhefox f6293b6812 fix(usage): void cancelled usage and cancel dropped streams 2026-06-29 00:30:41 +08:00
elky 7e9424008f Add usage queue worker autoscaling 2026-06-26 14:02:57 +08:00
elky 6c5e70ccb1 fix monitoring error totals and counter health 2026-06-26 10:48:45 +08:00
elky 063834e95b Split admin operations dashboard route 2026-06-26 01:48:37 +08:00
elky c76d6b6396 Add admin operations dashboard and usage state fixes 2026-06-26 01:32:45 +08:00
elky 6f00e9fc67 Improve gateway transport and usage runtime 2026-06-25 22:36:27 +08:00
elky d336d1a7fa Improve gateway scheduling and runtime admission 2026-06-24 01:53:45 +08:00
ZheFox cf0af8fa1e Merge pull request #652 from zhefox/main
fix(usage): preserve token counts in body redaction
2026-06-23 14:40:59 +08:00
zhefox fd220b6c42 fix(usage): preserve token counts in body redaction 2026-06-23 14:38:39 +08:00
zhefox 3472bb75e7 ci: combine gateway clippy and nextest jobs 2026-06-23 14:06:43 +08:00
zhefox ba65c96c74 Merge branch 'main' of https://github.com/zhefox/Aether 2026-06-23 13:36:00 +08:00
zhefox c54b214657 ci: shard gateway tests and disable debug info in rust ci 2026-06-23 13:35:56 +08:00
ZheFox 4fcc17114f Merge pull request #651 from zhefox/main
fix(ai-formats): accept Claude context_management in responses conversion
2026-06-23 10:43:26 +08:00
zhefox deb5f55786 fix(ai-formats): clean up cross-format safety rules for Gemini requests 2026-06-23 10:30:13 +08:00
zhefox 1836c2b652 fix(ai-formats): accept Claude context_management in responses conversion 2026-06-23 10:03:55 +08:00
elky 5b7805181b perf: queue request candidate persistence 2026-06-22 02:49:17 +08:00
elky f75894acbb perf: reduce gateway db pressure under load 2026-06-22 00:08:48 +08:00
elky 541cc197c4 fix: preserve in-memory user export fallback 2026-06-22 00:08:48 +08:00
fawney19 363d1aba9a Merge pull request #615 from AAEE86/main
feat: 健康监控仪表盘与关联下钻优化,完善使用记录展示
2026-06-21 12:48:13 +08:00
fawney19 eb2cf662b7 Merge pull request #650 from stabey/pr/claude-system-responses-20260620
fix(ai-formats): preserve Claude in-message system guidance in Responses
2026-06-21 12:47:26 +08:00
elky 900f8a7163 fix(pool): allow zero cooldown settings 2026-06-21 12:20:08 +08:00
elky 61bdd304b7 Handle inactive PAT owner as invalid OAuth token 2026-06-21 11:39:20 +08:00
elky 279735ae7f Auto-size SQL pool defaults 2026-06-21 11:15:40 +08:00
elky cc2830f6ec Merge branch 'review/pr-639' 2026-06-21 10:48:49 +08:00
elky 8dbd730568 fix: respect imported oauth authorization headers 2026-06-21 02:27:06 +08:00
stabey bb6aa03485 fix(ai-formats): strip Claude billing headers from preserved guidance 2026-06-21 00:22:57 +08:00
stabey 6a22488698 fix(ai-formats): preserve Claude in-message system guidance in responses 2026-06-21 00:07:57 +08:00
elky f1c30439ff fix: preserve provider auth metadata 2026-06-20 22:11:42 +08:00
fawney19 1123095bb7 Merge pull request #624 from MMEXA/codex/fix-antigravity-oauth-quota
修复 Antigravity OAuth 导入后配额复检缺 project
2026-06-19 23:11:38 +08:00
MMEXA 938f11981d fix(ai-serving): route Antigravity auth enum through facade 2026-06-19 22:25:52 +08:00
MMEXA 6c4e730e60 修复 Antigravity OAuth 配额复检缺 project 2026-06-19 22:21:22 +08:00
elky 16584067d7 Add route-backed routing profile views 2026-06-18 02:06:34 +08:00
fawney19 6de0fe75a4 Merge pull request #641 from Kayphoon/codex/usage-cleanup-break-condition
fix(usage): align cleanup loop break conditions with candidate row count
2026-06-17 11:04:55 +08:00
fawney19 34f0913ed0 Merge pull request #645 from zhefox/main
修复 OpenAI Chat/Responses/Messages 转换兼容性并透传 Codex cyber_policy 错误
2026-06-17 11:03:29 +08:00
zhefox 5b305c64e1 fix(ai-formats): omit request tool call ids in OpenAI Responses input 2026-06-17 09:15:38 +08:00
zhefox 8ad97761e8 fix(ai-formats): preserve OpenAI Responses tool call item ids 2026-06-17 08:29:25 +08:00
zhefox 0f92ef664d fix(ai-formats): support OpenAI Responses custom tool/raw passthrough 2026-06-17 04:24:56 +08:00
zhefox 16a4fd3687 Merge branch 'main' of https://github.com/zhefox/Aether 2026-06-17 04:07:53 +08:00
zhefox 3a3fcbe46a fix(ai-formats): preserve OpenAI tool call item ids 2026-06-17 04:05:09 +08:00
zhefox 18d8ea2052 fix(ai-formats): preserve OpenAI tool call item ids 2026-06-17 04:03:40 +08:00
zhefox 6ab08f4014 fix(ai-formats): preserve Claude raw blocks, reasoning tokens, and test stack safety 2026-06-17 03:45:23 +08:00
zhefox 628a3a0d8d fix(ai-formats): support cyber policy failover and custom tool/audio passthrough 2026-06-17 02:33:14 +08:00
elky f52628e00b Handle OpenAI Responses keepalive stream events 2026-06-16 22:52:06 +08:00
zhefox f9d97ececb fix(ai-formats): ignore OpenAI Responses metadata events 2026-06-16 22:34:38 +08:00
fawney19 803e555022 Merge pull request #635 from zhefox/main
fix(gateway): 支持 OpenAI 图片编辑端点请求
2026-06-16 22:31:52 +08:00
zhefox b1bd727978 将 JSON 提示注入为 developer 输入 2026-06-16 21:40:15 +08:00
zhefox c2748dc868 忽略 OpenAI Responses keepalive 事件 2026-06-16 20:00:46 +08:00
ZheFox 302620cb94 Merge branch 'fawney19:main' into main 2026-06-16 12:17:28 +08:00
AAEE86 c255f29e98 Merge remote-tracking branch 'upstream/main' 2026-06-16 10:53:47 +08:00
Kayphoon 6d1b818414 fix(usage): align cleanup loop break conditions with candidate row count
The cleanup loop break condition used rows_affected() from the UPDATE
statement, but for rows that only had blob/audit refs (no inline
compressed body data), the UPDATE reported 0 affected rows. This caused
the loop to exit after the first batch, skipping the majority of
candidates.

Change the break condition in all 4 cleanup functions from:
  if cleaned == 0 || cleaned < batch_size
to:
  if rows.len() < batch_size

This ensures the loop continues as long as SELECT returns a full batch,
regardless of how many rows the UPDATE actually modified.

Affected functions:
- cleanup_usage_raw_body_fields
- cleanup_usage_compressed_body_fields
- cleanup_usage_header_fields
- cleanup_usage_stale_body_fields
2026-06-16 04:19:58 +08:00
elky 669636d3e4 Harden PII redaction format conversion 2026-06-14 20:36:57 +08:00
elky 68038c182b Distinguish expired OAuth token status 2026-06-12 19:43:59 +08:00
elky 308cc88ef7 Fix provider deletion cleanup 2026-06-12 16:25:11 +08:00
elky 30b545785f feat: improve failover rules and request timeline 2026-06-11 00:49:29 +08:00
elky 31fade82f6 Preserve OpenAI encrypted reasoning blocks 2026-06-10 20:02:03 +08:00
elky 0246ba93dd fix(transport): preserve safe accept encoding 2026-06-10 19:58:57 +08:00
elky ff7ec8575c fix(ai-formats): ignore null stream errors 2026-06-10 18:44:46 +08:00
elky aa58cb4a05 build: speed up release image linking 2026-06-10 18:37:23 +08:00
elky e9b4efc2d4 fix(ai-serving): preserve explicit request encoding 2026-06-10 18:16:55 +08:00
elky ea76f7bb0b Support OpenAI Responses builtin tool stream items 2026-06-10 14:49:13 +08:00
elky 8edcbdcb29 feat(usage): expose request timing details 2026-06-10 09:16:15 +08:00
ndllz 5249660e07 fix: respect oauth module disabled state 2026-06-09 18:13:07 +08:00
ndllz 84b99a641a fix: speed up usage activity heatmap render 2026-06-09 16:52:58 +08:00
AAEE86 4824e4a487 fix(frontend): 移除账号导入重复处理中提示 2026-06-09 16:40:45 +08:00
zhefox ba723ebe48 fix(usage): always use truncated body placeholder when limit exceeded 2026-06-09 09:59:10 +08:00
zhefox 04ba8cbe9e fix(gateway): support openai image accept negotiation 2026-06-08 20:40:30 +08:00
elky 84f41dae77 feat(format): audit same-format compatibility rewrites 2026-06-08 16:12:37 +08:00
zhefox 82040bfc21 fix(gateway): support OpenAI image edit requests 2026-06-08 13:22:08 +08:00
elky 6155ffefcc fix(format): avoid false cache-control conversion blocks 2026-06-08 00:52:48 +08:00
elky bf4279a590 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	crates/aether-ai-formats/src/formats/openai/chat/stream.rs
#	crates/aether-ai-formats/src/formats/openai/responses/response.rs
#	crates/aether-ai-formats/src/formats/shared/sync_products.rs
2026-06-08 00:17:30 +08:00
elky 77759fac54 feat: 新增提供商批量处理功能 2026-06-07 22:57:02 +08:00
elky 63a2fd4dcf Merge origin/main into dev 2026-06-06 03:11:38 +08:00
elky 7a19891c60 fix: distinguish unaudited conversion fields 2026-06-06 00:35:27 +08:00
AAEE86 85573d7980 Merge remote-tracking branch 'upstream/main' 2026-06-05 08:28:43 +08:00
zhefox ebd59246a8 fix(test): assert responses timestamps and output text in finalize tests 2026-06-04 17:35:10 +08:00
zhefox fd27f55fe5 fix(provider): normalize OpenAI Responses modern fields and stream events 2026-06-04 15:45:37 +08:00
fawney19 69b8b96fb8 Merge pull request #625 from stabey/pr/responses-call-items-cache-control-20260604
fix: 剥离 Codex cache_control 并完善 Responses 工具调用展示
2026-06-04 13:59:56 +08:00
fawney19 19d1d36043 Merge pull request #620 from zhefox/main
fix(provider): 修复 Chat reasoning_effort 值域与 Responses 扩展透传
2026-06-04 13:59:42 +08:00
stabey 9f19ca5754 fix(usage): keep streamed call args in responses completion
The response.completed fallback rebuilt every call item with responsesCallInput(), which returns '{}' for a function_call lacking arguments. Since '{}' is truthy, ensureToolCall overwrote arguments already collected from streamed delta events. Guard the completed branch with responsesCallHasInput (matching the output_item.done branch) so empty/default inputs no longer clobber streamed args, and align its dedupe key with the streaming phase to avoid duplicate tool-call rendering when an item has no id. Drop the now-dead '工具调用' fallbacks since responsesCallName never returns empty.
2026-06-04 13:30:20 +08:00
stabey 2de2a792f6 fix(codex): strip cache_control before responses upstream 2026-06-04 12:01:36 +08:00
stabey ada690624b fix(usage): render responses call items in conversation view 2026-06-04 11:06:36 +08:00
elky 465476985b fix: preserve provider schema drift safely 2026-06-03 22:29:24 +08:00
elky da5624c98e chore: add format field coverage generator 2026-06-03 21:44:33 +08:00
elky b2f68bbaf7 feat: enforce full format field coverage audit 2026-06-03 21:18:49 +08:00
elky 7507af5829 feat: audit strict format conversion contracts 2026-06-03 20:27:15 +08:00
zhefox 5e39801bba fix(provider): clamp reasoning effort and filter chat extensions 2026-06-03 10:52:26 +08:00
elky 5ac153a0bb Fix gateway nextest stack limit 2026-06-03 01:32:25 +08:00
elky c7a5155ce4 Fix sync CLI test stack overflow 2026-06-03 01:12:40 +08:00
elky 869c3d3037 Fix finalize local test stack overflow 2026-06-03 00:48:36 +08:00
elky ef6a11c146 fix(gateway): preserve heartbeat no-path fallback 2026-06-03 00:25:01 +08:00
elky 21432911de Merge remote-tracking branch 'origin/pr/605' 2026-06-03 00:16:22 +08:00
elky eb98340924 Merge remote-tracking branch 'origin/pr/604' 2026-06-02 23:34:59 +08:00
elky 746af0d93e Fix Kiro cache usage reporting 2026-06-02 23:06:29 +08:00
elky 08ac9c5c58 Merge remote-tracking branch 'origin/pr/614' 2026-06-02 22:10:41 +08:00
elky bce3bf2b6e Merge remote-tracking branch 'origin/pr/593' 2026-06-02 21:40:59 +08:00
elky 4ec9ca61cf Merge remote-tracking branch 'origin/pr/613'
# Conflicts:
#	apps/aether-gateway/src/tests/usage/direct.rs
2026-06-02 19:27:50 +08:00
elky 657e6aa672 Merge remote-tracking branch 'origin/pr/619' 2026-06-02 19:23:44 +08:00
AAEE86 7835840ebd feat(dashboard): 增加全站实时指标和自动刷新
- 管理员仪表盘新增全站 RPM/TPM 与在线/启用用户指标
- 合并今日请求/费用、全站 RPM/TPM、在线/启用用户卡片展示
- 在线用户按最近 5 分钟活跃请求去重统计
- 全站 RPM/TPM 按最近 60 秒请求与 Token 统计
- 新增仪表盘自动刷新按钮,开启后每 10 秒静默刷新数据
- 同步前端类型、空态占位和仪表盘测试
2026-06-02 18:16:24 +08:00
zhefox 6cabcd85aa fix(provider): preserve Claude messages defaults in responses conversion 2026-06-02 17:14:26 +08:00
elky 03e436707d Fix gateway usage nextest stack overflow 2026-06-02 16:59:13 +08:00
elky 781bc5ac58 Merge branch 'pr-617' 2026-06-02 10:40:34 +08:00
AAEE86 86f72da3d9 feat(health): 增加历史状态条指标 Tooltip
- 为健康监控时间轴返回 timeline_details 分段指标
- Hover 历史状态柱时展示总请求/成功/失败/可用率/状态
- 展示平均耗时/TTFB/速度和完整时间范围
- 修复历史状态柱 Tooltip 触发区域不可用的问题
- 补齐前端类型、详情抽屉透传和 mock 数据
2026-06-02 10:36:54 +08:00
elky 0a2c674ad8 Ignore tunnel release tags for app build version 2026-06-02 09:43:12 +08:00
zhefox 0daa8c196b fix(provider): preserve reasoning and Claude tool results in responses conversion 2026-06-02 09:04:55 +08:00
zhefox 98dc5925a5 fix(provider): preserve openai responses tool history in chat conversion 2026-06-02 00:35:19 +08:00
AAEE86 d5d3f09846 refactor(health): add dashboard overview and related drill-down
- Replace health monitor tabs with a dashboard layout
- Add related health drill-down for endpoint, model, and provider cards
- Render provider health as cards and hide empty monitors
2026-06-02 00:10:59 +08:00
AAEE86 0e6fc96eb1 test(gateway): run wallet usage settlement test on larger stack
Wrap the wallet settlement usage test with the large-stack async test helper to
avoid stack overflow in the default test thread.
2026-06-01 22:40:22 +08:00
AAEE86 b052f40ffb test(gateway): run base usage body capture test on larger stack
Wrap the request_record_level=base local gateway usage test with the existing
large-stack async test helper to avoid stack overflow in the default test thread.
2026-06-01 22:23:54 +08:00
AAEE86 2aef9d2478 Refine mobile usage record metadata layout 2026-06-01 21:59:45 +08:00
AAEE86 8627a18f2e test(gateway): run local usage report test on large stack
Wrap the local OpenAI chat sync usage-reporting test in the existing
large-stack harness to avoid stack overflows under nextest suite load.
2026-06-01 21:45:44 +08:00
AAEE86 21c478be22 fix(health): hide empty endpoint monitors
- Remove raw API format label from endpoint health cards
- Hide endpoint health cards with no requests
2026-06-01 21:24:20 +08:00
AAEE86 9d8f7d158b Refine mobile usage record details
- Move mobile usage actions into the card header
- Add compact user/provider metadata line on mobile
- Preserve hidden unknown toggle and auto refresh controls
2026-06-01 21:13:10 +08:00
AAEE86 c1649fe837 refactor(health): consolidate monitor components 2026-06-01 20:49:38 +08:00
AAEE86 d3c8317939 fix(health): align model health card layout 2026-06-01 18:54:56 +08:00
AAEE86 7ffe33f867 feat(health): refine health monitor metrics
- add TPS to model and provider health payloads

- exclude user-cancelled 499 requests from health statistics

- update model/provider health cards with average latency, average TTFB, TPS, and availability
2026-06-01 18:34:51 +08:00
elky 6c2a57f237 fix rust ci failures 2026-06-01 02:42:10 +08:00
github-actions[bot] 0f4141ef3f chore(tunnel): update download links for tunnel-v0.3.16 2026-05-31 17:46:42 +00:00
elky 37413c0211 Refactor tunnel stability protocol 2026-06-01 01:36:49 +08:00
Entropy.Xu d1b64b6748 修复:完善 Kiro 模拟缓存共享回收 2026-05-31 22:43:27 +08:00
Entropy.Xu c2bcfab7d4 修复:Kiro 模拟缓存接入共享运行时 2026-05-31 22:13:45 +08:00
elky 392353ffff Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import' 2026-05-31 20:52:26 +08:00
Entropy.Xu 9734be31cf 修复:收敛 Kiro 模拟缓存断点语义 2026-05-31 20:45:16 +08:00
elky 905453d62b revert: remove usage elapsed clock calibration 2026-05-31 20:30:51 +08:00
Entropy.Xu a3b8a99709 修复:补齐 Kiro 模拟缓存 TTL 和消息级断点 2026-05-31 20:22:18 +08:00
Entropy.Xu 2e24e5f358 修复:扩大 Kiro 模拟缓存前缀读取范围 2026-05-31 19:52:18 +08:00
github-actions[bot] 40eb3cf6e1 chore(tunnel): update download links for tunnel-v0.3.15 2026-05-31 11:17:51 +00:00
elky 549463088c chore: bump aether-tunnel version to 0.3.15 2026-05-31 19:09:56 +08:00
elky f8b5651883 Support encoded tunnel node names 2026-05-31 16:33:06 +08:00
stabey de0a880ca6 test(gateway): 修复 usage wallet 测试栈溢出 2026-05-31 03:35:01 +08:00
stabey ba4e194cb5 test(gateway): 修复 usage base 记录测试栈溢出 2026-05-31 03:21:29 +08:00
stabey 1c05a722c1 test(gateway): 修复 usage local 同步测试栈溢出 2026-05-31 03:09:11 +08:00
stabey eda94913cf test(gateway): 修复 usage 同步测试栈溢出
CI 中 gateway_records_pending_usage_before_execution_runtime_sync_result_arrives 仍会在默认测试栈上溢出。

复用 large-stack tokio runtime 包装该测试,避免 gateway 全量测试在无业务失败时被 SIGABRT 中断。
2026-05-31 02:54:11 +08:00
stabey 3dfafbc379 fix(ai): 按 Responses 文本分片去重快照
upstream 已有 8abedecb 处理单个 OpenAI Responses 文本流中 delta 与 done/completed 快照重复输出的问题。

本提交保留该方向,并把去重状态从全局文本扩展为按 output_index/item_id 与 content_index 分片记录,避免多个 message item 或多个 text content part 共用同一段快照状态。
2026-05-31 02:54:11 +08:00
stabey 8d1e54eba6 fix(stream): 中途失败时不合成正常收尾
上游流式读取失败后,已经缓冲的局部转换状态可能是不完整的工具调用。

在 terminal failure 存在时跳过 normalizer 和 rewriter 的 finish 路径,避免把半截 tool_use 补成正常的 Claude message_stop。
2026-05-31 02:54:11 +08:00
stabey 6bfd56b54f fix(usage): 避免上游流式错误误记为成功
当上游流式响应中途失败时,sync error payload 可能同时包含合成错误体和部分上游流 body。

优先使用合成错误体生成 usage 终态,避免只因为上游先返回过 200 和部分 SSE 内容就把失败请求记录为 completed/settled。
2026-05-31 02:54:11 +08:00
elky 49f952692b Fix remaining sync chat stack overflows 2026-05-31 02:12:22 +08:00
elky fde15c9b60 Fix sync chat test stack overflow 2026-05-31 01:13:09 +08:00
elky 06f26cfacf Merge remote-tracking branch 'origin/pr/597' 2026-05-31 00:09:42 +08:00
elky 5360665432 test(gateway): avoid stack overflow in cors proxy test 2026-05-30 22:48:19 +08:00
elky a20ac1d31f fix(pool): align oauth status filter with visible state 2026-05-30 21:37:21 +08:00
elky 1bdd300606 Merge branch 'review-pr-612' 2026-05-30 21:26:18 +08:00
elky c56f0198ff Merge branch 'review-pr-611' 2026-05-30 21:26:12 +08:00
elky 02fc6bd4ef Merge branch 'review-pr-610' 2026-05-30 21:26:07 +08:00
elky c3a8352d76 Merge branch 'review-pr-603' 2026-05-30 21:25:58 +08:00
elky 463576915f Merge branch 'review-pr-602' 2026-05-30 21:25:52 +08:00
elky 1db6b9d307 Merge branch 'review-pr-596' 2026-05-30 21:25:46 +08:00
elky b5a02a118f Merge branch 'review-pr-595' 2026-05-30 21:25:39 +08:00
elky ae96d5d61b fix usage trace active key selection 2026-05-30 19:48:18 +08:00
cym ce1d532e3c fix(pool): align status filters with visible key state 2026-05-30 18:49:26 +08:00
Entropy.Xu f27485ec05 fix(kiro): 忽略图片 base64 token 估算 2026-05-30 02:59:04 +08:00
Entropy.Xu 9616f458de fix(kiro): 模拟缓存读取移动断点前缀 2026-05-30 00:42:14 +08:00
MMEXA 3455faf7da 修复格式转换优先级保持的首轮候选排序
让开启格式转换优先级保持的跨格式候选进入首轮候选页。

普通跨格式候选仍延后到后续页,保持原有兜底语义。
2026-05-29 22:01:11 +08:00
Entropy.Xu 7ed4b84654 feat(ccswitch): 添加一键导入和用量查询 2026-05-29 21:39:45 +08:00
github-actions[bot] 0d76a8e478 chore(tunnel): update download links for tunnel-v0.3.14 2026-05-29 13:32:35 +00:00
elky b9612fef9b chore: bump aether-tunnel version to 0.3.14 2026-05-29 21:21:30 +08:00
fawney19 92ae88f1be fix: avoid postgres migration version collision 2026-05-29 16:18:59 +08:00
ZheFox 91a5e58cec Merge branch 'fawney19:main' into main 2026-05-29 15:27:46 +08:00
fawney19 1658925f52 Disable key circuit breaker for pool providers 2026-05-29 15:16:06 +08:00
Entropy.Xu bb5a4454a5 feat(gateway): 添加标准文本非流式心跳 2026-05-29 14:35:16 +08:00
fawney19 9fb600df1b Fix PR 599 check regressions 2026-05-29 12:30:56 +08:00
ZheFox fff4fe4e20 Merge branch 'fawney19:main' into main 2026-05-29 12:27:38 +08:00
fawney19 3e4dfd2bac Merge branch 'pr-599' 2026-05-29 02:46:07 +08:00
fawney19 8bd82c8c95 Update endpoint base URL placeholders 2026-05-29 02:44:03 +08:00
fawney19 b59c724455 Normalize endpoint API root handling 2026-05-29 02:29:33 +08:00
ZheFox 3ee272fd53 Merge branch 'fawney19:main' into main 2026-05-29 01:48:50 +08:00
AAEE86 ab5d1f266f fix(usage): Optimize the billing layout of the request details page for mobile devices 2026-05-29 00:01:51 +08:00
Entropy.Xu 906742e3c4 fix(billing): 复用待支付套餐订单 2026-05-28 23:09:36 +08:00
AAEE86 0ee45f41e1 feat(mobile): Refine mobile usage record layout 2026-05-28 22:43:20 +08:00
RWDai 6d285410c2 Preserve dashboard daily breakdown rows 2026-05-28 22:02:42 +08:00
Entropy.Xu eaabfb83ed fix(tunnel): bound upstream clients and heartbeat deltas 2026-05-28 20:34:08 +08:00
fawney19 ef2953038e Fix usage records filtering and pool trace display 2026-05-28 20:24:48 +08:00
zhefox cc1a63bf01 fix: repair missing routing profiles snapshot 2026-05-28 18:59:04 +08:00
Novick Yuan 4b2d8cef3c fix(usage): calibrate active elapsed clock efficiently 2026-05-28 18:45:16 +08:00
fawney19 df518ad668 fix contracts usage server time header 2026-05-28 17:54:56 +08:00
fawney19 37b0c00701 Merge remote-tracking branch 'origin/main' 2026-05-28 17:19:04 +08:00
fawney19 88f03aaef2 Keep key circuit breaker out of pool scoring 2026-05-28 17:18:42 +08:00
fawney19 ffd8d273c4 Revert "Merge remote-tracking branch 'origin/pr/592'"
This reverts commit 3504875922, reversing
changes made to 5c3a1aecbe.
2026-05-28 17:10:27 +08:00
fawney19 ef2a96bcc4 Remove default hot pool size cap 2026-05-28 17:01:03 +08:00
Novick Yuan 734717899b Invalidate model routing cache after admin model writes 2026-05-28 16:57:28 +08:00
RWDai d2d28c30d9 Use bearer auth for OpenAI embedding passthrough 2026-05-28 16:53:00 +08:00
fawney19 10532e1a55 Merge pull request #594 from AAEE86/main
feat(usage): support output_config effort badge source
2026-05-28 16:48:02 +08:00
fawney19 47886abd2b Preserve streaming usage timing on refresh 2026-05-28 16:33:10 +08:00
fawney19 d076f64db3 Harden usage server timing header passthrough 2026-05-28 16:28:19 +08:00
AAEE86 60e3ffc402 feat(usage): support output_config effort badge source
- extract reasoning effort from provider request body output_config.effort
- include output_config.effort in usage list fallback SQL
- cover the new request body shape in usage metadata tests
2026-05-28 16:14:37 +08:00
fawney19 b21be24faa Merge remote-tracking branch 'origin/pr/591' 2026-05-28 16:07:08 +08:00
fawney19 3504875922 Merge remote-tracking branch 'origin/pr/592' 2026-05-28 16:07:07 +08:00
Entropy.Xu 0f6d4b9146 feat(embedding): 接入阿里云多模态向量端点 2026-05-28 16:05:36 +08:00
Mas0nShi 6ebd39ed0b Stabilize stream first-byte usage test 2026-05-28 15:33:28 +08:00
fawney19 5c3a1aecbe Merge remote-tracking branch 'origin/pr/591' 2026-05-28 15:23:26 +08:00
Mas0nShi 1a45ec9386 Fix Gemini CLI streaming policy for OpenAI chat 2026-05-28 15:05:05 +08:00
fawney19 97133f657f style: 突出路由策略选中标签样式 2026-05-28 15:03:14 +08:00
Novick Yuan b108dc5ea6 Assert usage server timing over HTTP 2026-05-28 15:01:59 +08:00
Novick Yuan 35cf44b38e Extract active usage elapsed clock 2026-05-28 14:30:15 +08:00
Novick Yuan 6412294262 Use header-only usage server timing 2026-05-28 14:30:15 +08:00
Novick Yuan 01c8592ca6 Align admin user usage timing samples 2026-05-28 14:30:15 +08:00
Novick Yuan 9b5c3ecd23 Use shared clock for active usage timers 2026-05-28 14:30:15 +08:00
Novick Yuan 6de684df59 Track server clock offset for usage data 2026-05-28 14:30:15 +08:00
Novick Yuan 8aca1f8b93 Add server time to usage responses 2026-05-28 14:30:15 +08:00
fawney19 bb2fc2ec00 Optimize health monitor database reads 2026-05-28 14:24:37 +08:00
fawney19 18566b5837 Merge remote-tracking branch 'origin/pr/587' 2026-05-28 13:56:04 +08:00
fawney19 069e1c1e60 Fix merged PR check regressions 2026-05-28 13:53:04 +08:00
fawney19 2c28d9979c Merge commit 'refs/pr/585'
# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs
#	apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs
#	apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs
#	apps/aether-gateway/src/tests/control/admin/provider_query.rs
#	crates/aether-provider-transport/src/gemini_cli/mod.rs
#	crates/aether-provider-transport/src/gemini_cli/request.rs
#	crates/aether-provider-transport/src/gemini_cli/url.rs
#	crates/aether-provider-transport/src/lib.rs
2026-05-28 13:19:42 +08:00
fawney19 0efb3d340d Merge commit 'refs/pr/530' 2026-05-28 12:53:45 +08:00
fawney19 535039c29e Merge remote-tracking branch 'origin/pr/584' 2026-05-28 12:16:51 +08:00
fawney19 93d3de1644 feat: improve routing policy diagnostics 2026-05-28 12:11:43 +08:00
AAEE86 4ce056fe45 feat(health): add model and provider health monitoring
- Rename the original health monitor to endpoint health monitor
- Add tab navigation for endpoint, model, and provider health views
- Add model health monitor cards with availability, latency, first-byte latency, and 60-point history
- Add admin-only provider health monitor with collapsible active-provider sections
- Show per-provider model health cards after expanding a provider
- Add backend model health and provider health monitor payload builders
- Add admin endpoint for provider health monitoring
- Add provider-scoped usage breakdown filtering for per-provider model statistics
- Add frontend API types and request helpers for model/provider health data
- Add demo mock data for model and provider health monitoring
- Fix model health timeline time-unit handling so request history segments render correctly

Verification:
- cargo fmt
- npm run type-check
- npm run build
- cargo test -p aether-gateway health_models
- cargo test -p aether-gateway health_providers
- cargo test -p aether-gateway gateway_exposes_frontdoor_manifest_without_proxying_upstream
2026-05-28 12:00:20 +08:00
Mas0nShi 9ad9858ac2 Merge origin/main into fix/gemini-cli-v1internal 2026-05-28 11:58:00 +08:00
MMEXA adca142d1e fix: adapt gemini cli to v1internal endpoint 2026-05-28 00:24:56 +08:00
stabey 739e39e1ca fix: 修复缓存 token usage 转换语义
统一 OpenAI、Gemini、Claude 之间缓存 token 的 usage 语义,避免 Claude 侧重复统计缓存输入 token。

同时补充 stream 合并逻辑、字段注释和覆盖转换链路的测试。
2026-05-27 23:49:07 +08:00
fawney19 14ad6e9b75 Merge remote-tracking branch 'origin/pr/583' 2026-05-27 18:42:57 +08:00
fawney19 d46d225a90 暗色模式下交换流式徽章填充与描边样式 2026-05-27 18:38:59 +08:00
ZheFox c05d227df2 Merge branch 'fawney19:main' into main 2026-05-27 17:06:31 +08:00
fawney19 42e723ff7c Clarify API key concurrency skip reasons 2026-05-27 17:00:09 +08:00
ZheFox b02d62642a Merge branch 'fawney19:main' into main 2026-05-27 16:18:21 +08:00
zhefox 8abedecb16 fix(ai): dedupe OpenAI responses text snapshot deltas 2026-05-27 16:11:02 +08:00
fawney19 d77a572dc7 fix: cast usage provider body before jsonb type checks 2026-05-27 15:48:12 +08:00
fawney19 8606455355 Merge pull request #581 from zhefox/main
fix(gateway): preserve JSON mode chat hints in responses normalization
2026-05-27 15:40:17 +08:00
fawney19 21e52722e6 Prefer provider request body for usage badges 2026-05-27 15:39:39 +08:00
fawney19 6673ab6d4a Add fast model directive service tier 2026-05-27 15:08:23 +08:00
fawney19 d488b1a680 fix auth refresh request body 2026-05-27 15:06:51 +08:00
fawney19 b9ac97ebc3 Simplify request detail cost overview 2026-05-27 14:24:57 +08:00
zhefox e09d3199c1 fix(gateway): update codex prompt cache key test 2026-05-27 13:57:48 +08:00
fawney19 ccfc4cbddc Cache provider catalog lookups 2026-05-27 13:56:39 +08:00
zhefox 41ad422002 fix(gateway): preserve JSON mode chat hints in responses normalization 2026-05-27 13:35:16 +08:00
fawney19 674cc85005 Stabilize stream runtime nextest timing 2026-05-27 11:00:04 +08:00
fawney19 dd2da69361 Merge pull request #580 from AAEE86/main
fix(mobile): improve usage and pool management layouts
2026-05-27 10:23:17 +08:00
fawney19 0ee6e393ce Record stream first byte on upstream event 2026-05-27 10:19:49 +08:00
fawney19 433a4d3c7d test(gateway): run claude pii redaction cases on large stack 2026-05-27 09:21:57 +08:00
AAEE86 049f26c03b fix(mobile): improve usage and pool management layouts
- Fix pool account batch dialog scrolling on mobile
- Rework usage records mobile filters into clearer rows
- Align user filter styling with other select filters
- Improve request detail drawer metric layout on mobile
2026-05-27 09:20:10 +08:00
fawney19 cf8372c8cb fix(usage): record visible stream first byte timing 2026-05-27 02:48:01 +08:00
fawney19 f03550415b style(usage): make fast badge white 2026-05-27 02:11:59 +08:00
fawney19 5a710c4f5e Merge remote-tracking branch 'origin/pr/578' 2026-05-27 02:07:52 +08:00
fawney19 56901f91ce Merge remote-tracking branch 'origin/pr/576' 2026-05-27 02:06:22 +08:00
fawney19 1109c3547c Merge branch 'pr-577' 2026-05-27 01:35:25 +08:00
fawney19 d24ead234d Merge branch 'pr-575'
# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/family/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
2026-05-27 01:34:19 +08:00
fawney19 d816ae5c88 Merge remote-tracking branch 'origin/pr/573' 2026-05-27 01:06:57 +08:00
fawney19 8c6e586063 Merge remote-tracking branch 'zhefox/main' 2026-05-27 01:01:34 +08:00
fawney19 c632ec616d Merge remote-tracking branch 'origin/pr/564' 2026-05-27 00:52:15 +08:00
fawney19 bd71a46c25 Merge pull request #561 from Kayphoon/codex/s3-integrated-backup 2026-05-27 00:48:59 +08:00
fawney19 e2b5c3acc8 docs: remove simple query inventory 2026-05-27 00:45:03 +08:00
AAEE86 e27ca671fd feat(usage): show reasoning and fast badges in usage records
- extract provider reasoning effort from request body metadata
- extract priority service tier and expose it as service_tier
- show reasoning level and fast badges after model names
- include badges in active request updates and usage list payloads
- add targeted backend and frontend coverage
2026-05-27 00:36:52 +08:00
fawney19 614c999871 feat(admin): expose s3 backup as module 2026-05-27 00:30:37 +08:00
fawney19 42693c2c52 chore: remove s3 backup docs 2026-05-27 00:07:37 +08:00
fawney19 e21cd72181 fix: preserve pool scan budget for exhausted accounts 2026-05-26 23:49:26 +08:00
MMEXA a9e6a7d644 fix(frontend): recover login redirect navigation 2026-05-26 23:22:30 +08:00
yangrs ba72770cab fix: wire windsurf oauth runtime scheduling 2026-05-26 22:40:46 +08:00
Kayphoon 7530bec7de test(gateway): cover chat pii redaction formats 2026-05-26 22:29:45 +08:00
zhefox 1173a4d9d5 fix(provider): split partial model fetch warnings from errors 2026-05-26 17:45:06 +08:00
zhefox aa409a8a9c fix(provider): refine endpoint default paths for openai and claude roots 2026-05-26 16:50:36 +08:00
AAEE86 949e251b2e fix(provider): 模型测试按 Key 模型权限过滤
测试模型前检查 provider key 的 allowed_models:
- 空权限视为允许所有模型
- 非空权限需匹配请求模型或映射后的实际模型
- 不匹配的 key 标记为跳过,避免发起测试请求

同时补充相关单测和前端跳过原因文案。
2026-05-26 16:46:21 +08:00
fawney19 4933ae9014 Merge pull request #572 from AAEE86/main
fix(admin): add User-Agent for Done-hub provider ops
2026-05-26 16:19:02 +08:00
AAEE86 1793443b09 fix(admin): add User-Agent for Done-hub provider ops
- Done-hub Cookie 请求增加浏览器 User-Agent
- 覆盖认证验证和余额查询的共享请求头
- 补充请求头测试,确认 Cookie 与 User-Agent 同时发送
2026-05-26 16:07:02 +08:00
ZheFox 23a36e37bb Merge branch 'fawney19:main' into main 2026-05-26 15:56:17 +08:00
zhefox c9cf1d458a fix(provider): factor model fetch route test type alias 2026-05-26 15:56:03 +08:00
zhefox d28a389a93 fix(provider): support unversioned API roots in model fetch 2026-05-26 15:39:56 +08:00
fawney19 7e76c9763d Clarify stream first byte timeout message 2026-05-26 15:02:50 +08:00
Kayphoon 9e029462aa test(gateway): stabilize stream timeout regression 2026-05-26 14:41:56 +08:00
Kayphoon 4523a2c67b fix(data): cast MySQL usage aggregates 2026-05-26 14:41:56 +08:00
Kayphoon 84c8bc960e feat(admin): add configurable S3 backups 2026-05-26 14:41:56 +08:00
zhefox c4927162b7 Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-26 13:57:27 +08:00
zhefox 1ebe0aeadf fix(users): allow clearing explicit admin group memberships 2026-05-26 13:57:22 +08:00
ZheFox 992c58f2bd Merge branch 'fawney19:main' into main 2026-05-26 13:08:59 +08:00
zhefox 0bf63cc80e fix(provider): support multi-key selection in model tests 2026-05-26 13:08:35 +08:00
zhefox 5fc6dc8019 Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-26 12:31:03 +08:00
zhefox 96184caa48 fix(codex): strip unsupported OpenAI responses body fields 2026-05-26 12:30:58 +08:00
fawney19 12ff87949d fix(ai): preserve combined Gemini builtin tools 2026-05-26 11:14:24 +08:00
fawney19 b75953bf4c Merge remote-tracking branch 'origin/pr/569' 2026-05-26 11:11:13 +08:00
MMEXA c733139091 fix(ai): normalize Gemini search grounding tools 2026-05-26 05:33:34 +08:00
fawney19 331d37be26 test: run sub2api balance provider ops on larger stack 2026-05-26 02:30:39 +08:00
fawney19 57ccd44b89 fix: fill provider quota execution timeout defaults 2026-05-26 02:11:10 +08:00
fawney19 d60b6e7454 test: run gemini image bridge case on larger stack 2026-05-26 01:52:01 +08:00
fawney19 50e4f27276 Merge remote-tracking branch 'origin/pr/567' 2026-05-26 01:21:03 +08:00
fawney19 a0f22ae659 fix: tighten pr 566 claude and deepseek handling 2026-05-26 00:57:28 +08:00
fawney19 235f32e10e Merge remote-tracking branch 'origin/pr/566' into review/pr-566-fix 2026-05-26 00:43:45 +08:00
fawney19 e7b3acdec3 fix(provider): format oauth import tests 2026-05-25 23:48:04 +08:00
fawney19 f3a367b02d Merge commit 'refs/pull/563/head' of github-fawney19:fawney19/Aether into review/pr-562 2026-05-25 23:44:02 +08:00
fawney19 c03aebba3f Merge branch 'pr-562' into review/pr-562 2026-05-25 23:10:29 +08:00
fawney19 4fb8955bc2 fix(provider): move key model auto-match into dialog 2026-05-25 23:03:52 +08:00
Novick Yuan 5dfccdec3e Fix stream candidate watchdog timeout semantics 2026-05-25 21:43:13 +08:00
fawney19 8b386b0aac Merge remote-tracking branch 'origin/pr/555' 2026-05-25 21:14:45 +08:00
hemo94931 9010f0806a fix(ai): sanitize Claude Read pages passthrough 2026-05-25 21:01:10 +08:00
hemo94931 495795327c fix(ai): sanitize empty Read pages for Claude tools 2026-05-25 21:01:10 +08:00
root 686311eabf test: align OpenAI image stream keepalive expectation 2026-05-25 21:01:10 +08:00
root e68b843875 Add DeepSeek thinking compatibility 2026-05-25 21:00:08 +08:00
root b46028cb85 refactor: clarify OpenAI SSE control policy 2026-05-25 21:00:08 +08:00
root fa172ecb95 fix: avoid synthetic keepalive for OpenAI streams 2026-05-25 21:00:08 +08:00
root 431311979a fix: handle split streaming terminal events 2026-05-25 20:58:17 +08:00
fawney19 d3249485fa Fix stream timeout semantics 2026-05-25 20:09:37 +08:00
zhefox 4c22a819f9 fix(provider): always show batch assign models action 2026-05-25 20:05:13 +08:00
zhefox f4d66021e4 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	crates/aether-data/src/repository/usage/mysql.rs
2026-05-25 17:29:12 +08:00
fawney19 54c5d5803b fix: cast mysql usage aggregate counters 2026-05-25 15:36:13 +08:00
fawney19 ae138ddb56 feat: update admin config import and runtime handling 2026-05-25 15:23:02 +08:00
zhefox b72abec2fc fix(gateway): spawn oauth account refresh asynchronously 2026-05-25 15:09:41 +08:00
zhefox db4f3fd210 fix(usage): cast mysql usage aggregates to numeric types 2026-05-25 13:56:12 +08:00
zhefox 230ce5df5f Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-25 13:38:38 +08:00
zhefox ec681335e8 fix(usage): treat empty body_state as missing terminal event 2026-05-25 13:38:18 +08:00
Entropy.Xu aaad113190 feat(admin-users): 支持按创建时间排序 2026-05-25 12:21:58 +08:00
calida-tec 63681b4be3 fix(provider): accept common OAuth token JSON aliases 2026-05-25 10:02:47 +08:00
MMEXA b347f1816d Fix native Antigravity stream envelope handling 2026-05-25 09:39:55 +08:00
ZheFox e9efc5c42a Merge branch 'fawney19:main' into main 2026-05-25 09:15:13 +08:00
MMEXA 28c3a5dbe4 Add Antigravity v1internal gateway adapter 2026-05-25 06:58:15 +08:00
fawney19 505d9fd8bc Merge remote-tracking branch 'origin/main' 2026-05-25 01:56:34 +08:00
fawney19 932397d1b3 fix(gateway): defer ChatGPT web image quota decrement 2026-05-25 01:56:25 +08:00
fawney19 1be445423c Merge remote-tracking branch 'origin/pr/558' 2026-05-25 01:22:03 +08:00
fawney19 2df9615fb9 Merge pull request #560 from Kayphoon/codex/remove-install-migration
fix(install): remove pg single-node migration entrypoint
2026-05-25 01:21:30 +08:00
fawney19 fe7fb17ff5 fix(gateway): align admin key health circuit summary 2026-05-25 01:18:07 +08:00
Kayphoon 72d43878ef fix(install): remove pg single-node migration entrypoint 2026-05-25 01:14:37 +08:00
fawney19 cc3ce8b5d7 Merge remote-tracking branch 'origin/pr/557' 2026-05-25 01:08:22 +08:00
fawney19 d4ae6e0e64 fix: read imported usage aggregates in dashboards 2026-05-25 00:51:46 +08:00
MMEXA 480579a0d5 fix(gateway): expire provider key circuit cooldowns 2026-05-25 00:40:38 +08:00
ZheFox ba188aea92 Merge branch 'fawney19:main' into main 2026-05-24 23:40:40 +08:00
fawney19 40b4e52508 Filter format-scoped key fields on import 2026-05-24 22:44:12 +08:00
fawney19 e2d5fc9dfb Preserve usage data in system imports 2026-05-24 21:40:48 +08:00
zhefox c92bdfba16 Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-24 18:51:28 +08:00
ZheFox 83a2609344 Merge branch 'fawney19:main' into main 2026-05-24 18:51:00 +08:00
fawney19 18d9004f22 fix docker app logging permissions 2026-05-24 18:50:39 +08:00
Codex 74c8bfc59f 调整 ChatGPT Web 生图 token 估算口径 2026-05-24 18:50:14 +08:00
Codex 3bf7469d30 修复 ChatGPT Web 生图 usage 估算 2026-05-24 18:50:14 +08:00
Codex 66837b7d7f 修复 ChatGPT Web 生图发起即扣额度 2026-05-24 18:50:14 +08:00
Codex 14b182d09b 修复 ChatGPT Web 生图调用起始预扣额度 2026-05-24 18:50:14 +08:00
Codex 9dba6ec1d9 修复 ChatGPT Web 生图预扣与 Free 限额继承 2026-05-24 18:50:14 +08:00
Codex a52b513533 调整 ChatGPT Web 生图请求预扣额度 2026-05-24 18:50:14 +08:00
Codex 218ca8e6eb 修复 ChatGPT Web 生图额度递减显示 2026-05-24 18:50:14 +08:00
Codex 2b2754b779 修复 ChatGPT Web 生图成功后额度同步 2026-05-24 18:50:13 +08:00
Codex 952d1c840d 修复 ChatGPT Web 额度刷新 403 误判 2026-05-24 18:50:13 +08:00
zhefox 2207b60834 fix(usage): preserve failed status for active request refreshes 2026-05-24 18:48:50 +08:00
ZheFox c09bb28d16 Merge branch 'fawney19:main' into main 2026-05-24 18:07:09 +08:00
github-actions[bot] 13e0759d5a chore(tunnel): update download links for tunnel-v0.3.13 2026-05-24 08:43:41 +00:00
fawney19 80054276eb chore: bump aether-tunnel version to 0.3.13 2026-05-24 16:36:57 +08:00
fawney19 517c9e5108 Merge pull request #552 from RWDai/issue-549-fix
Bound models route data reads
2026-05-24 16:17:41 +08:00
fawney19 576918daa5 Optimize provider scheduler database hotspots 2026-05-24 15:47:56 +08:00
zhefox bbd4338e8e Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-24 15:21:27 +08:00
zhefox e6423a91aa feat(provider): auto-match batch assign models from key 2026-05-24 15:19:37 +08:00
fawney19 78523f122d Limit pool score interest feedback writes 2026-05-24 12:35:55 +08:00
fawney19 e1df06f06c revert compose data layout to legacy paths 2026-05-24 00:37:03 +08:00
fawney19 ecfe04f48a Merge pull request #554 from zhefox/main 2026-05-24 00:15:47 +08:00
ZheFox ff7f27d4f8 Merge branch 'fawney19:main' into main 2026-05-23 23:46:18 +08:00
fawney19 dd07425d21 Merge remote-tracking branch 'origin/pr/550'
# Conflicts:
#	frontend/src/features/usage/components/UsageRecordsTable.vue
2026-05-23 23:40:10 +08:00
ZheFox ba9aa7c1bd Merge branch 'fawney19:main' into main 2026-05-23 23:32:40 +08:00
zhefox 92fd253cae Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-23 22:24:09 +08:00
zhefox 9f6fac418e feat(usage): normalize provider stats across usage and cost views 2026-05-23 22:22:37 +08:00
fawney19 e53a2757eb Merge branch 'pr-548' 2026-05-23 22:19:51 +08:00
fawney19 db32b1d982 fix(usage): ignore truncated stream captures for terminal inference 2026-05-23 22:16:02 +08:00
fawney19 6b1c1e4f50 Merge remote-tracking branch 'origin/pr/547' 2026-05-23 22:03:17 +08:00
fawney19 3eb9614e68 Merge pull request #546 from novcky/fix/pool-scheduler-skip-invalid-oauth-accounts
修复 Provider Pool 热池反复调度已失效 OAuth 账号的问题
2026-05-23 21:38:18 +08:00
fawney19 8087a98c9d Merge remote-tracking branch 'origin/pr/475'
# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/execution.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/response.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/errors.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs
#	apps/aether-gateway/src/state/oauth.rs
#	apps/aether-gateway/src/tests/control/admin/oauth.rs
#	crates/aether-admin/src/provider/quota.rs
2026-05-23 21:26:22 +08:00
fawney19 5643b2c901 feat: add compose data layout migration helper 2026-05-23 20:51:02 +08:00
fawney19 18eac2dd7a feat: clarify deployment update strategies 2026-05-23 20:14:26 +08:00
ZheFox 0f2a96554f Merge branch 'fawney19:main' into main 2026-05-23 19:54:24 +08:00
RWDai 0655e868a2 Bound models route data reads 2026-05-23 19:36:20 +08:00
fawney19 4b66cadf15 Merge remote-tracking branch 'origin/pr/544' 2026-05-23 18:41:52 +08:00
Kayphoon 4ee64339ba fix(usage): show retry marker with fallback 2026-05-23 17:54:48 +08:00
Kayphoon babe328565 fix(usage): include embedding formats in filters 2026-05-23 16:19:23 +08:00
fawney19 6447fda852 fix: harden tunnel security and timeout handling 2026-05-23 14:17:04 +08:00
fawney19 74f7348529 Merge remote-tracking branch 'origin/pr/535' 2026-05-23 13:00:18 +08:00
wzw bf456450d7 feat: 代理池均衡分发&批量添加代理节点 2026-05-23 10:40:25 +08:00
zhefox 91cb2bbbcc fix(usage): refine stream terminal capture gating for OpenAI responses 2026-05-23 03:31:15 +08:00
Novick Yuan c0252387b4 修复热池调度已失效 OAuth 账号 2026-05-23 03:17:09 +08:00
zhefox bd1e155332 fix(provider): normalize OpenAI chat tool history for Claude messages 2026-05-23 02:45:04 +08:00
fawney19 ab048b8a03 fix monitoring trace lookup fallback 2026-05-23 01:22:24 +08:00
zhefox e5ce2ac7a4 fix(usage): detect missing terminal events in stream reporting 2026-05-23 00:47:36 +08:00
fawney19 c7641dad0a fix: propagate build version through local deploy 2026-05-23 00:26:07 +08:00
fawney19 b5e942ca9d fix: increase postgres shared memory for dashboard queries 2026-05-23 00:21:34 +08:00
fawney19 74c82d9948 Merge remote-tracking branch 'origin/main' 2026-05-22 23:59:05 +08:00
fawney19 d18b13a91a fix: harden frontdoor and usage ingestion 2026-05-22 23:57:38 +08:00
Mas0nShi 0d80db8a8d Refactor Gemini CLI v1internal planner request builder 2026-05-22 18:57:03 +08:00
Mas0nShi 8cc6888c5b Fix Gemini CLI OpenAI conversion envelope 2026-05-22 18:40:11 +08:00
fawney19 9af1507238 Merge pull request #545 from RWDai/feat/expand-client-types
feat: expand client type recognition
2026-05-22 18:36:53 +08:00
Mas0nShi c67818ee86 Fix Gemini CLI standard conversion envelope 2026-05-22 18:24:40 +08:00
fawney19 6ef6cbade2 Fallback dashboard aggregate reads on schema mismatch 2026-05-22 17:52:36 +08:00
Mas0nShi 8df0e1790d Fix Gemini CLI batch import parse error entry 2026-05-22 17:25:13 +08:00
Mas0nShi 8b7643e150 Merge remote-tracking branch 'origin/main' into fix/gemini-cli-v1internal
# Conflicts:
#	apps/aether-gateway/src/ai_serving/transport.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs
#	apps/aether-gateway/src/handlers/shared/catalog.rs
#	crates/aether-admin/src/provider/quota.rs
#	crates/aether-model-fetch/src/strategy.rs
#	crates/aether-provider-pool/src/lib.rs
#	crates/aether-provider-pool/src/service.rs
#	crates/aether-provider-transport/src/provider_types.rs
#	frontend/src/features/providers/components/ProviderDetailDrawer.vue
#	frontend/src/utils/__tests__/providerKeyQuota.spec.ts
#	frontend/src/utils/providerKeyQuota.ts
#	frontend/src/views/admin/PoolManagement.vue
2026-05-22 17:13:57 +08:00
fawney19 ef04f4b0fb Add automatic B/T compact unit formatting 2026-05-22 17:13:16 +08:00
Mas0nShi ce02f1ae8c Add Gemini CLI v1internal quota support 2026-05-22 16:58:04 +08:00
RWDai c2d0f60784 fix(gateway): accept header sessions for unknown clients 2026-05-22 16:10:27 +08:00
zhiqicloud 781830a202 fix: resolve gateway clippy regressions 2026-05-22 15:54:51 +08:00
RWDai 9dd545353c Preserve omitted tunnel security in CLI mode 2026-05-22 15:54:10 +08:00
zhiqicloud 4c7ebf8b8d style: format admin update settings 2026-05-22 15:32:49 +08:00
zhiqicloud a4a5f70a10 Merge upstream/main into feat/one-click-update 2026-05-22 15:28:27 +08:00
RWDai 9633bce2e1 feat(frontend): centralize client family labels 2026-05-22 15:28:19 +08:00
RWDai ca341703bc feat(usage): infer additional client families 2026-05-22 15:27:39 +08:00
RWDai cb2ff61bbc feat(gateway): expand client session family detection 2026-05-22 15:26:56 +08:00
RWDai 08b27806a7 Respect explicit tunnel security off 2026-05-22 15:20:36 +08:00
zhiqicloud b59c3a9e3b feat: support admin online update and deploy flow 2026-05-22 15:15:17 +08:00
fawney19 ecf6019ccb Merge pull request #543 from zhefox/main
Accept Claude message bodies in OpenAI chat endpoints
2026-05-22 14:53:25 +08:00
zhefox 2a298de971 fix(provider): serialize Claude tool results as JSON strings 2026-05-22 14:41:27 +08:00
RWDai 33633637e5 Fix secure tunnel session handling 2026-05-22 14:35:43 +08:00
fawney19 8ede01ad4e Merge remote-tracking branch 'origin/main' 2026-05-22 14:16:07 +08:00
fawney19 8966fd6aac Protect background workers under DB pool pressure 2026-05-22 14:11:47 +08:00
ZheFox 2b8ff8a743 Merge branch 'fawney19:main' into main 2026-05-22 14:11:00 +08:00
zhefox 97de4ff8a3 fix(gateway): accept Claude Messages bodies on OpenAI chat endpoints 2026-05-22 14:09:51 +08:00
fawney19 f6c3ebf7d3 Merge pull request #542 from zhefox/main
Handle OpenAI chat body responses and SSE passthrough
2026-05-22 12:05:36 +08:00
ZheFox 56abfdf39d Merge branch 'fawney19:main' into main 2026-05-22 11:51:53 +08:00
zhefox 9b95fa4d95 fix(gateway): handle responses-shaped OpenAI chat bodies and SSE passthrough 2026-05-22 11:39:38 +08:00
fawney19 f341c573eb Merge pull request #541 from AAEE86/main
feat(notification): add Bark push support
2026-05-22 11:24:11 +08:00
fawney19 b227669985 Merge pull request #540 from zhefox/main
fix(usage): treat stream terminal failures as failures on HTTP 200
2026-05-22 11:23:53 +08:00
AAEE86 ce44d35eb6 feat(notification): add Bark push support
Add Bark as a notification-service delivery channel, including encrypted Device Key configuration, server URL/template settings, module status integration, and admin UI support.
2026-05-22 11:08:00 +08:00
RWDai b05f2a270a Fix gateway secure tunnel Clippy warning 2026-05-22 10:13:06 +08:00
RWDai 2e701a90c9 Complete secure tunnel encryption support 2026-05-22 09:47:28 +08:00
zhefox 507f2f8250 fix(usage): treat stream terminal failures as failures on HTTP 200 2026-05-22 09:34:22 +08:00
Mas0nShi 9533bd7043 fix: route Gemini CLI generateContent through stream 2026-05-22 09:30:55 +08:00
fawney19 2b32b9a445 Fix system data import export flows 2026-05-22 02:46:49 +08:00
fawney19 3714c211dc Merge pull request #534 from RWDai/fix/issue-528-cache-affinity-health
fix(gateway): preserve cache affinity during health updates
2026-05-22 02:10:45 +08:00
fawney19 504c1ccb37 feat: restructure notification services 2026-05-22 01:45:46 +08:00
fawney19 d5e64d6ad9 Merge branch 'pr-503'
# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/summary/value.rs
#	apps/aether-gateway/src/lib.rs
#	apps/aether-gateway/src/maintenance/mod.rs
#	apps/aether-gateway/src/maintenance/runtime/workers.rs
#	frontend/src/api/endpoints/types/provider.ts
2026-05-22 00:19:23 +08:00
RWDai 9859aec16c fix(gateway): evict pooled affinity after sibling key failures 2026-05-21 23:50:34 +08:00
fawney19 0e6d7539ad Merge remote-tracking branch 'origin/pr/538' 2026-05-21 23:12:42 +08:00
fawney19 d6eb41aa78 Merge remote-tracking branch 'origin/pr/536'
# Conflicts:
#	apps/aether-gateway/src/execution_runtime/stream/execution.rs
2026-05-21 23:03:14 +08:00
fawney19 97997685b5 Merge remote-tracking branch 'origin/pr/498' 2026-05-21 22:56:43 +08:00
fawney19 ab0a90de97 fix(runtime-state): govern redis connections 2026-05-21 22:53:37 +08:00
ZheFox eeb7995214 Merge branch 'fawney19:main' into main 2026-05-21 22:27:07 +08:00
zhefox 68d8f86dc6 fix(gateway): stop stream polling on downstream disconnect and preserve Codex cache keys 2026-05-21 22:26:14 +08:00
RWDai 18fe5a4f11 fix(gateway): preserve affinity during adaptive retries 2026-05-21 21:56:26 +08:00
RWDai 26ee1a9958 fix(gateway): preserve affinity during quota telemetry 2026-05-21 21:56:26 +08:00
zhefox 77c2d91eb0 fix(gateway): drain downstream-disconnected streams and stop inferring cancelled usage 2026-05-21 20:30:44 +08:00
fawney19 b8a65cbdec Merge pull request #532 from RWDai/opencode/cosmic-nebula
fix: raise group rate limits by access tier
2026-05-21 19:39:50 +08:00
zhefox 71f9afc526 fix(gateway): move openai responses helper import to ai_serving module 2026-05-21 18:09:57 +08:00
zhefox 8ca4a10f24 fix(gateway): require terminal events for OpenAI responses streams 2026-05-21 17:46:21 +08:00
Mas0nShi 66f21de50e fix: unwrap Gemini CLI model test envelopes 2026-05-21 17:39:10 +08:00
Mas0nShi 3e6ce6cf4a fix: hydrate Gemini CLI project metadata 2026-05-21 17:10:17 +08:00
stabeyandClaude Opus 4.7 cadc45c5b8 fix(data): cleanup uses failed candidate status instead of 504
The stale-pending cleanup task previously hardcoded status_code=504 and a
generic timeout message for every usage row it finalized. When a request
had already been observed as failing — e.g. upstream Connection reset by
peer, watchdog 504, or an authenticated 4xx — the cleanup overwrote that
context with a misleading "服务器超时" outcome and 504 status, hiding the
real cause from the dashboards and customer.

Pull the most recent failed/cancelled candidate per stale request_id and,
if present, finalize the usage row with the candidate's status_code
(defaulting to 502 when none was recorded) and error_message. Requests
that have no terminal candidate (truly stuck pending/streaming) keep the
existing 504 + timeout-message behavior, since they really are timeouts
from the cleanup's perspective. Applied to all three SQL backends with
parameterized UPDATE statements.

The Postgres failed-candidate lookup orders by
COALESCE(finished_at, started_at, created_at) DESC, matching the MySQL
and SQLite ORDER BY clauses so the three backends pick the same
"most recent terminal candidate" under every NULL combination of timing
columns.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 16:56:56 +08:00
zhefox b7b7b4f718 fix(gateway): report terminal stream failure errors consistently 2026-05-21 16:15:03 +08:00
RWDai 4f49dd5943 Document tunnel security MVP config 2026-05-21 16:09:38 +08:00
RWDai 888414c41b Forward proxy tunnel security aliases 2026-05-21 16:09:00 +08:00
RWDai 8f41bc1558 Generate secure tunnel install sessions 2026-05-21 16:08:37 +08:00
RWDai a543ca9e07 Add tunnel installer security envs 2026-05-21 16:08:14 +08:00
RWDai 40b9db3545 Add tunnel security setup fields 2026-05-21 16:07:56 +08:00
RWDai bd4f6b9206 Add tunnel security config fields 2026-05-21 16:07:27 +08:00
RWDai 776f95b1ab chore: format auth rate limit tests for rustfmt 1.95 2026-05-21 15:53:40 +08:00
zhefox 12abde2aeb fix(usage): handle terminal stream failures and preserve usage updates 2026-05-21 15:52:51 +08:00
RWDai 965a8c79af fix(gateway): preserve cache affinity during health updates 2026-05-21 15:51:56 +08:00
RWDai d33043288d fix(frontend): clarify user group policy help 2026-05-21 15:38:53 +08:00
RWDai a2ad556f2b fix(gateway): raise group rate limits by tier 2026-05-21 15:38:42 +08:00
Mas0nShi e53d5f07e8 feat: support Gemini CLI v1internal quota 2026-05-21 15:38:10 +08:00
stabeyandClaude Opus 4.7 40434005c0 fix(gateway): use total_ms for non-stream upstream watchdog
When the endpoint forces upstream_stream_policy=force_non_stream while
the client streams, the local stream candidate watchdog still preferred
timeouts.first_byte_ms — a non-stream upstream produces no early first
byte, so the watchdog fired before the HTTP request_timeout and aborted
otherwise-healthy attempts at ~300s.

Read upstream_is_stream from report_context and invert the priority:
non-stream upstreams use total_ms first, falling back to first_byte_ms
and then the default; streaming upstreams keep the previous order.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 15:26:42 +08:00
stabeyandClaude Opus 4.7 3330b2ac4c refactor(report-context): extract UPSTREAM_IS_STREAM_KEY constant
The "upstream_is_stream" JSON key flows from the AI execution report
context producer (aether-ai-serving::report_context) through several
consumers — usage runtime metadata copy/move, gateway watchdog, sync
execution decision, observability handlers, and the per-driver usage
repositories. Each site spelled the key as a bare string literal, so a
producer-side rename would silently degrade every consumer to its
fallback (typically assuming streaming) with no compile-time signal.

Introduce a single pub const UPSTREAM_IS_STREAM_KEY in
aether-ai-formats (the lowest crate every consumer already depends on),
re-export from the crate root, and route producer + all map-style
consumers through it. The change is purely a string-literal → constant
swap; behaviour is identical.

Sites left as literals (intentional):
- `json!({"upstream_is_stream": ...})` macro keys, which must be string
  literals at the macro layer; these are also API-response payload
  field names (an external contract that should not silently track
  internal report-context renames).
- SQL column accessors (`try_get::<...>("upstream_is_stream")`), which
  refer to the database schema column, not the JSON key.
- Test fixtures and assertions, which validate the on-the-wire contract
  and should keep verifying the actual string.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 15:26:42 +08:00
zhefox be6e49b9c2 Merge branch 'main' of https://github.com/zhefox/Aether 2026-05-21 12:30:48 +08:00
zhefox e59e6c3797 fix(gateway): sanitize Claude thinking and handle missing stream finish 2026-05-21 12:30:42 +08:00
Entropy.Xu 6b04a0a3a6 fix(windsurf): 修复 native 工具流式回程 2026-05-21 02:55:05 +08:00
Entropy.Xu 4112a8b2ea fix(provider): 修复 Windsurf PR CI 失败 2026-05-21 02:21:35 +08:00
fawney19 b84e4a96e2 chore: tune default postgres settings for 2c4g 2026-05-21 01:36:05 +08:00
fawney19 e7f8b259ac Revert "Merge pull request #517 from zhiqicloud/feat/provider-balance-query"
This reverts commit 7e95e769d5, reversing
changes made to 490306c242.
2026-05-21 01:24:17 +08:00
Entropy.Xu 65c361115a fix(provider): 修复 Windsurf PR 冲突残留 2026-05-21 01:02:02 +08:00
Entropy.Xu 129c7c90c0 fix(provider): 修复 Windsurf 原生工具桥接 2026-05-21 01:02:02 +08:00
Entropy.Xu 82637ad882 fix(provider): 修复 Windsurf Connect 请求与端点计数 2026-05-21 01:02:02 +08:00
Entropy.Xu 931c577345 fix(provider): 接入 Windsurf 模型测试链路 2026-05-21 01:02:02 +08:00
Entropy.Xu 9466d92a7a fix(provider): 接入 Windsurf 模型拉取和格式转换 2026-05-21 01:02:02 +08:00
Entropy.Xu 0a0a8b31c7 fix(provider): 隐藏 Windsurf refresh token 刷新入口 2026-05-21 01:02:02 +08:00
Entropy.Xu 208c77a062 fix(provider): 对齐 Windsurf PostAuth 登录链路 2026-05-21 01:02:02 +08:00
Entropy.Xu 02d1343436 feat(provider): 补充 Windsurf 邮箱密码导入表单 2026-05-21 01:02:02 +08:00
Entropy.Xu 0226e14251 feat(provider): 原生接入 Windsurf provider 2026-05-21 01:02:02 +08:00
fawney19 923515ab28 fix: align image generation checks 2026-05-21 00:45:02 +08:00
fawney19 4d0c654822 Merge remote-tracking branch 'origin/pr/524' 2026-05-20 23:17:31 +08:00
ZheFox 779877acd0 Merge branch 'fawney19:main' into main 2026-05-20 22:49:49 +08:00
fawney19 d49b0a8a45 Make extension modules reorderable 2026-05-20 22:45:32 +08:00
ZheFox 5a9f19cbf2 fix(gateway): filter upstream SSE control-only blocks 2026-05-20 22:33:41 +08:00
zhiqicloud 9562295d8b feat: 添加在线更新功能 2026-05-20 22:29:11 +08:00
ZheFox 3c6924238f feat(usage): include cache token details in stream usage payloads 2026-05-20 21:20:10 +08:00
ZheFox 64ad0f694b feat(usage): include cache token details in stream usage payloads 2026-05-20 21:06:51 +08:00
fawney19 754f672ee2 Merge pull request #526 from MMEXA/fix/codex-responses-pending-recovery-20260520
修复 Codex Responses 工具字段和成功请求回收标记
2026-05-20 21:01:54 +08:00
fawney19 e50ceeba5a Merge pull request #525 from final0920/fix/issue-505-priority-order
fix: 修复优先级管理重新打开顺序回退
2026-05-20 21:00:06 +08:00
fawney19 57910f906d Preserve usage provider identity 2026-05-20 20:56:36 +08:00
ZheFox 8838e9289b fix(provider): remove stale image preview block from model test dialog 2026-05-20 20:53:13 +08:00
ZheFox d3355a8a09 fix(gateway): decode stream-encoded provider response JSON 2026-05-20 20:40:48 +08:00
fawney19 c972bbd397 Fix provider pool exhaustion scheduling 2026-05-20 20:16:57 +08:00
MMEXA fed9bdf01a Fix Codex Responses tool schema and pending recovery 2026-05-20 12:02:00 +00:00
ZheFox ab2287202d feat(provider): show image previews in model test dialog 2026-05-20 19:59:20 +08:00
流云 b47282fe4c fix: 修复优先级管理重新打开顺序回退
优先级管理弹窗依赖 grouped-by-format 接口回显格式优先级,但该接口此前读取 summary key 行。summary 查询会清空 global_priority_by_format 和 internal_priority 等路由字段,导致保存后的数据库顺序存在,重新打开页面却回退为前端占位顺序。

改为使用完整 key 查询,并增加 summary 字段被清空时仍能回显真实优先级的回归测试。

Fixes #505

Constraint: grouped-by-format 是优先级管理弹窗的数据源,必须返回真实 per-format priority 字段。
Rejected: 修改前端继续猜测顺序 | 无法区分真实数据库优先级与占位回退。
Confidence: high
Scope-risk: narrow
Tested: cargo fmt --check; git diff --check
Not-tested: cargo test on local Windows blocked by missing NASM for boring-sys2
2026-05-20 19:44:05 +08:00
ZheFox a31e237cc3 Merge upstream main 2026-05-20 19:28:49 +08:00
ZheFox cfa32a2b4d fix(gateway): preserve openai image 200 responses and sync success reporting 2026-05-20 19:17:42 +08:00
ZheFox 2cacf66a37 fix(gateway): route openai image streams with images surface 2026-05-20 18:13:34 +08:00
fawney19 d0981c2fd5 Merge pull request #522 from RWDai/fix/admin-users-server-pagination
Fix admin users server-side pagination
2026-05-20 18:03:11 +08:00
RWDai 3e12c06627 Fix user group options cache busting 2026-05-20 17:51:29 +08:00
RWDai 74a3df3f1e Fix user group options cache invalidation 2026-05-20 17:46:16 +08:00
fawney19 cb894208a1 Merge pull request #521 from Avilianb/fix/provider-query-responses-compact-body
Fix provider model test compact request bodies
2026-05-20 17:42:53 +08:00
fawney19 76752beca6 chore(postgres): 支持通过环境变量配置 PG 性能参数 2026-05-20 17:41:52 +08:00
RWDai e80e7b0cfa Fix admin users pagination review issues 2026-05-20 17:41:06 +08:00
RWDai 77051e6245 Fix admin users pagination follow-ups 2026-05-20 17:32:47 +08:00
ZheFox de7be4f15b fix(gateway): route openai image api requests with mapped models 2026-05-20 17:16:38 +08:00
RWDai 44fb1af287 Fix admin user count clippy lint 2026-05-20 17:06:33 +08:00
fawney19 e7a76b0510 chore(docker): postgres 启用 pg_stat_statements 扩展 2026-05-20 17:00:44 +08:00
fawney19 2881ff097a feat(usage): 管理员用量统计支持筛选刷新与失败保留旧数据
- 用量统计/聚合接口新增 skipCache 选项与 120s 超时,便于强制绕过缓存
- loadStats 增加 force/preserveOnFailure 选项,背景刷新失败时保留旧数据
- 管理员页面在用户/模型/Provider 筛选变化时强制刷新统计,并将筛选条件传入统计接口
- 手动刷新与自动刷新分离,自动刷新不再重载长期热力图相关聚合
2026-05-20 16:51:52 +08:00
RWDai 462c3dde79 Load admin users with server-side pagination 2026-05-20 16:51:46 +08:00
RWDai 1be703b56e Track admin users pagination in frontend data layer 2026-05-20 16:51:46 +08:00
RWDai 5130da9710 Return paginated admin users metadata 2026-05-20 16:51:46 +08:00
RWDai 8440846bae Expose admin user export counts through gateway state 2026-05-20 16:51:46 +08:00
RWDai 831554f11d Add admin user export count queries 2026-05-20 16:51:46 +08:00
Avilianb 97fb588a4a Apply rustfmt to compact provider test 2026-05-20 16:47:30 +08:00
Avilianb cd994d57d2 Fix provider model test compact request bodies 2026-05-20 16:29:46 +08:00
fawney19 70f2882a43 refactor(api-keys): 将独立余额 Key 表单的额度、IP 限制、敏感信息保护移至左侧列 2026-05-20 16:28:58 +08:00
fawney19 fa5d26ce38 Merge remote-tracking branch 'origin/main' 2026-05-20 16:14:09 +08:00
fawney19 f76bbaab52 feat: support api key ip restriction rules 2026-05-20 16:11:49 +08:00
ZheFox cde2062618 chore(gateway): make openai image intent import local 2026-05-20 16:09:24 +08:00
ZheFox 9673fc4c01 fix(codex): trigger image override only on explicit tool_choice 2026-05-20 15:34:31 +08:00
fawney19 19ae7c8902 Merge pull request #519 from RWDai/feat/aether-tunnel-ip-family-options
feat(tunnel): add tunnel IP family controls
2026-05-20 15:30:17 +08:00
RWDai eb63838f6a fix(proxy): keep legacy installer URLs working 2026-05-20 14:43:24 +08:00
RWDai 232006f71d fix(tunnel): restore IP family flag test builds 2026-05-20 14:42:52 +08:00
fawney19 b6bdc08267 Merge branch 'pr-501' 2026-05-20 14:05:00 +08:00
fawney19 7e95e769d5 Merge pull request #517 from zhiqicloud/feat/provider-balance-query
支持 Provider Key 余额查询与自动刷新
2026-05-20 13:59:24 +08:00
RWDai f4c79c80ac fix(tunnel): allow explicit false IP family flags 2026-05-20 13:50:58 +08:00
RWDai edded777e7 docs(tunnel): document IP family controls 2026-05-20 13:43:22 +08:00
RWDai 7284165f39 feat(tunnel): add tunnel IP family controls 2026-05-20 13:42:55 +08:00
RWDai 1604a6d87d fix(gateway): allow clearing API key IP whitelists 2026-05-20 13:41:14 +08:00
ZheFox 7d5ad1e70e chore(gateway): silence dead code warnings in openai image bridge 2026-05-20 13:32:14 +08:00
ZheFox 89860bec97 fix(codex): restrict openai image routing to codex responses 2026-05-20 13:17:24 +08:00
zhiqicloud ebc1774300 修正 new_api 验证测试断言 2026-05-20 13:04:22 +08:00
zhiqicloud 122daf0f87 支持 Provider Key 余额查询与自动刷新 2026-05-20 12:33:31 +08:00
RWDai 149651f831 test(gateway): expect image bridge tools 2026-05-20 11:22:45 +08:00
fawney19 490306c242 Merge commit 'refs/pull/511/head' of github-fawney19:fawney19/Aether 2026-05-20 11:19:58 +08:00
RWDai 316b1e3207 Merge remote-tracking branch 'upstream/main' into feat/500-api-key-ip-whitelist 2026-05-20 11:14:43 +08:00
RWDai 84c4c2f9c2 fix(gateway): preserve image generation tools 2026-05-20 11:02:41 +08:00
fawney19 4d856f3deb Merge remote-tracking branch 'origin/pr/516' 2026-05-20 10:54:32 +08:00
fawney19 61bcbe826a fix: tighten OAuth auto cleanup signals 2026-05-20 10:34:22 +08:00
RWDai bdc848b19e Merge upstream main into feat/500-api-key-ip-whitelist 2026-05-20 10:26:56 +08:00
mayrain 65e3b6f3da fix(codex): trigger image override only on explicit tool_choice
The codex `apply_codex_openai_responses_special_body_edits` override
previously triggered whenever the `tools` array contained an
`image_generation` entry, regardless of whether the caller actually
asked to use it. Codex CLI advertises `image_generation` alongside
~20 other tools under `tool_choice: "auto"`, so every routine codex
conversation was being rewritten into image-generation-only form:

  - `model` forced to `gpt-5.4-mini` (CODEX_OPENAI_IMAGE_INTERNAL_MODEL)
  - `stream` forced to `true`
  - `tools` truncated to a single `image_generation` entry
  - `tool_choice` overwritten to `{"type":"image_generation"}`

The upstream ChatGPT codex backend then rejected the request with
`400 Tool choice 'image_generation' not found in 'tools' parameter`,
which the gateway surfaced as a retryable 503 to clients. The bug
reproduced on every codex CLI session that included the image tool
in its tool catalogue, even though the user never requested image
generation.

Narrow the trigger to the caller's actual selection. The new helper
`codex_openai_responses_tool_choice_references_image_generation`
matches only the explicit string `"image_generation"` or the object
form `{"type":"image_generation"}`. The pre-existing
`is_openai_image_request(provider_api_format)` branch still handles
genuine `openai:image` traffic, so true image-generation flows are
unaffected.

Tests:
  - lock the regression: `tool_choice: "auto"` with image_generation
    in tools must not trigger the override (model/tools preserved)
  - lock variants: string `"image_generation"` and object form both
    still trigger; other tool_choice values and an absent
    `tool_choice` do not
2026-05-20 09:52:39 +08:00
zhiqicloud 97b05e744f 支持官方直连支付、退款配置与套餐联动 2026-05-20 08:16:52 +08:00
fawney19 fbda210b84 Merge pull request #511 2026-05-20 01:22:55 +08:00
fawney19 ed75ae6d56 Merge pull request #509 from beilo/feat/key-ranking-usage
Add paginated API key usage leaderboard
2026-05-20 01:06:58 +08:00
fawney19 d1ad1815f7 Merge pull request #513 from mayrainnn/fix/request-body-content-encoding
feat: normalize compressed request bodies
2026-05-20 01:05:59 +08:00
fawney19 b1a3a26815 Merge pull request #512 from Entropy-Xu/codex/fix-wallet-overdraft-settlement
[codex] 修复钱包余额不足后重复消费
2026-05-20 01:05:26 +08:00
fawney19 94760dbc14 refactor(tunnel): rename aether-proxy to aether-tunnel 2026-05-20 01:02:01 +08:00
zhiqicloud 3a318a86b2 支持官方直连支付、退款配置与套餐联动 2026-05-20 00:21:56 +08:00
fawney19 f4d0d5904a Remove image_generation tools from OpenAI image bridges 2026-05-20 00:20:56 +08:00
fawney19 25c7bb935e chore(frontend): simplify concurrent limit hint text 2026-05-19 23:52:59 +08:00
fawney19 f5deed8709 refactor(proxy): improve tunnel throughput and observability 2026-05-19 23:49:36 +08:00
beilo fe3a848eb5 feat(admin): add paginated API key usage leaderboard 2026-05-19 23:17:10 +08:00
mayrain 8f4f4d2d82 refactor(gateway): route decoded body access through ai_serving 2026-05-19 22:58:03 +08:00
mayrain 66a54cc39e feat(gateway): normalize compressed request bodies 2026-05-19 22:57:45 +08:00
Entropy.Xu 7ace958710 fix(wallet): 修复余额不足后重复消费
- 有限钱包结算允许扣成负数,先扣充值余额再扣赠送余额,缺口回写到充值余额
- 日额度钱包补扣路径在存在钱包时不再把余额不足标成 insufficient_quota
- 补充内存和 SQLite 结算回归覆盖,三种数据库实现保持一致
验证:
- cargo fmt --all --check
- cargo clippy -p aether-data --all-targets -- -D warnings
- cargo clippy -p aether-gateway --all-targets -- -D warnings
- cargo clippy --workspace --exclude aether-gateway --exclude aether-data --all-targets -- -D warnings
- cargo nextest run -p aether-gateway
- cargo nextest run -p aether-data
- cargo nextest run --workspace --exclude aether-gateway --exclude aether-data
- cargo test -p aether-data sqlite --lib
- Postgres/MySQL data_db_smoke commands from rust-ci.yml
2026-05-19 19:29:22 +08:00
fawney19 57655bdb25 Hide capability tags from UI 2026-05-19 19:10:30 +08:00
fawney19 124077a0a1 Merge branch 'pr-506' 2026-05-19 18:36:36 +08:00
fawney19 1b570daf72 Revert "Merge PR #504"
This reverts commit d216a9e219, reversing
changes made to 21e82abd54.
2026-05-19 17:23:57 +08:00
fawney19 8bcd5b8189 Revert "Merge remote-tracking branch 'origin/pr-473'"
This reverts commit f2cdb74ed8, reversing
changes made to 3f0fd15395.
2026-05-19 16:31:58 +08:00
fawney19 63202a63ef Merge branch 'codex/pool-hot-trace-fix'
# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs
2026-05-19 14:47:37 +08:00
fawney19 7e9ca88e00 fix: restore provider key circuit breaker backoff 2026-05-19 13:57:36 +08:00
beilo 75aa3dc0cc fix: refine oauth auto-removal behavior 2026-05-19 13:41:18 +08:00
fawney19 6a1da6a5ff fix: speed up admin pool loading 2026-05-19 12:05:36 +08:00
mayrain d88f092dd1 feat(kiro): add simulated cache provider toggle 2026-05-19 10:47:17 +08:00
mayrain b4d17a392a feat(kiro): simulate prompt cache usage accounting 2026-05-19 10:47:17 +08:00
fawney19 c6a408d4e8 Fix local gateway Docker native deps 2026-05-19 10:43:04 +08:00
RWDai d19bf71343 Refresh migration cutoff expectations 2026-05-19 10:25:06 +08:00
RWDai 02f09c2056 Regenerate API key schema baselines 2026-05-19 10:24:47 +08:00
RWDai 6a104d5736 Add allowed IPs to data schema sources 2026-05-19 10:24:27 +08:00
RWDai 95af482ffe Update auth snapshot observability fixture 2026-05-19 10:23:54 +08:00
RWDai b05264ff74 Stabilize wallet today usage test timing 2026-05-19 10:23:32 +08:00
RWDai 2aab1ea97b Clean up gateway API key whitelist handlers 2026-05-19 10:23:15 +08:00
RWDai f1687017e6 Fix provider endpoint format normalization path 2026-05-19 10:22:50 +08:00
fawney19 052de6b96e test: stabilize merged PR checks 2026-05-19 10:18:25 +08:00
fawney19 d2b42e91d2 test: align cancelled sync billing status 2026-05-19 08:25:39 +08:00
fawney19 d8a9d7eb5e chore: format merged PR changes 2026-05-19 08:13:56 +08:00
fawney19 d216a9e219 Merge PR #504 2026-05-19 08:10:57 +08:00
fawney19 21e82abd54 Merge PR #502 2026-05-19 08:10:48 +08:00
fawney19 18ed9a57c2 Merge PR #499 2026-05-19 08:10:37 +08:00
fawney19 702dc3ceb4 Merge PR #489 (ours: keep current main) 2026-05-19 03:38:26 +08:00
fawney19 3180ca2bf9 Merge PR #488 (ours: keep current main) 2026-05-19 03:38:22 +08:00
fawney19 69af74b1e0 Merge PR #488 and #489 2026-05-19 03:16:35 +08:00
MMEXA ef9c0ebbd4 Merge latest origin/main into codex/gemini-embedding-batch
# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/mod.rs
#	apps/aether-gateway/src/execution_runtime/fallback.rs
2026-05-18 19:11:42 +00:00
MMEXA ca4d0dc819 Merge origin/main into codex/gemini-embedding-batch
# Conflicts:
#	apps/aether-gateway/src/ai_serving/api.rs
#	apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs
#	apps/aether-gateway/src/ai_serving/transport.rs
#	apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs
#	apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs
#	crates/aether-data/src/repository/candidate_selection/postgres.rs
#	crates/aether-model-fetch/src/strategy.rs
2026-05-18 19:02:19 +00:00
fawney19 cd1aa92931 Merge branch 'merge-pr-483' 2026-05-19 02:28:43 +08:00
fawney19 9fc9c334c9 Merge remote-tracking branch 'origin/pr/487'
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	crates/aether-data/src/repository/oauth_providers/postgres.rs
#	crates/aether-data/src/repository/oauth_providers/sqlite.rs
#	frontend/src/views/admin/OAuthSettings.vue
2026-05-19 02:27:39 +08:00
fawney19 c563c192b7 Merge remote-tracking branch 'origin/pr-483' into merge-pr-483
# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
#	apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
2026-05-19 02:23:14 +08:00
fawney19 afedd90c80 Merge commit 'refs/pull/481/head' of github-fawney19:fawney19/Aether
# Conflicts:
#	apps/aether-gateway/src/ai_serving/api.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
2026-05-19 01:46:41 +08:00
MMEXA be2e8e594c fix(frontend): align Vertex Gemini endpoint controls 2026-05-18 17:36:04 +00:00
fawney19 d392681c58 Merge branch 'pr-478'
# Conflicts:
#	apps/aether-gateway/src/data/state/mod.rs
#	apps/aether-gateway/src/handlers/admin/mod.rs
#	apps/aether-gateway/src/handlers/admin/routes.rs
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	crates/aether-data/src/repository/announcements/postgres.rs
#	frontend/src/features/auth/components/RegisterDialog.vue
2026-05-19 01:27:42 +08:00
MMEXA 5ed8325592 fix(gateway): use Vertex model garden catalog endpoint 2026-05-18 17:06:42 +00:00
fawney19 ed1d9fdb57 Fix postgres bigint counter decoding 2026-05-19 00:58:09 +08:00
ZheFox c58ce63fc3 Merge branch 'fawney19:main' into main 2026-05-19 00:50:52 +08:00
fawney19 5eed329916 Rootfix usage counter outbox 2026-05-19 00:44:37 +08:00
fawney19 19c8688eb1 Merge pull request #477 from RWDai/feat/356-usage-record-columns
Add configurable usage record columns
2026-05-19 00:35:30 +08:00
MMEXA 4317ff78b1 Expose local scheduling failures in usage UI 2026-05-18 16:28:37 +00:00
yangrsandClaude Opus 4.7 6c16f399d4 feat: 重要通知模块、Server 酱独立配置与额度提醒
- 新增重要通知统一模块(邮件 + Server 酱)作为后台任务通知出口
- 拆出独立的 Server 酱 配置页(SendKey + Markdown 模板,支持 {title}/{body} 变量替换),通过仪表盘内置工具入口进入
- 新增提供商额度提醒后台 worker:余额低于阈值时通过重要通知推送,提供商配置页加入额度提醒开关与阈值
- 重要通知页加入配置可用性守卫:未配置任一通道时禁用总开关,未配置邮件/SendKey 时禁用对应通道开关
- 测试通知端点支持 channel 过滤(all/email/server_chan),并绕过总开关与通道开关,便于配置阶段先验证通道
- 修复:测试通知路由未在 buffered-body 白名单导致 channel 参数丢失、测试时邮件分支被误触发
- 修复:sub2api 验证响应中 username 为 null 时正确回退到 email,避免误报"验证响应缺少: 用户信息"

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-18 23:51:14 +08:00
MMEXA b480f3aaff fix(gateway): normalize Gemini Vertex embedding transport 2026-05-18 15:39:29 +00:00
MMEXA 84f312fa4c fix(gateway): close dropped sync attempts 2026-05-18 14:15:22 +00:00
ZheFox 61d5fdb0ec fix(frontend): preserve fixed provider model test key inheritance 2026-05-18 22:06:27 +08:00
ZheFox 995ab302be Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/payloads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/update.rs
#	apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs
#	frontend/src/features/models/components/GlobalModelFormDialog.vue
#	frontend/src/features/providers/components/ProviderModelFormDialog.vue
#	frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts
#	frontend/src/features/providers/components/provider-tabs/model-test-request.ts
2026-05-18 22:02:31 +08:00
RWDai c6ee558180 Add admin user key IP whitelist UI 2026-05-18 20:48:28 +08:00
RWDai 460cd63d3a Add user API key IP whitelist UI 2026-05-18 20:48:21 +08:00
RWDai 2a3593d9c5 Update planner auth snapshot fixtures 2026-05-18 20:48:14 +08:00
RWDai bcca8d295a Update auth context test fixtures 2026-05-18 20:48:05 +08:00
RWDai d8f68c1d9a Preserve allowed IP defaults in admin key imports 2026-05-18 20:47:57 +08:00
RWDai ab53326865 Support allowed IPs in admin user key endpoints 2026-05-18 20:47:46 +08:00
RWDai 96b857f642 Support allowed IPs in user API key endpoints 2026-05-18 20:47:37 +08:00
RWDai fc12cc8a36 Enforce API key IP restrictions in proxy auth 2026-05-18 20:47:28 +08:00
RWDai 276d19b63c Persist allowed IPs in auth repositories 2026-05-18 20:47:17 +08:00
RWDai 437024cdb1 Add API key allowed IP data types 2026-05-18 20:47:03 +08:00
RWDai 64b41434ce Add API key allowed IP migrations 2026-05-18 20:46:53 +08:00
RWDai 24129d7f12 Merge upstream/main into feat/356-usage-record-columns 2026-05-18 20:33:05 +08:00
fawney19 d51b44d642 Merge remote-tracking branch 'origin/pr-485'
# Conflicts:
#	crates/aether-data/src/repository/provider_catalog/sqlite.rs
2026-05-18 19:44:18 +08:00
RWDai e8c55e8f1f Label OpenAI JS SDK in cache monitoring 2026-05-18 19:30:39 +08:00
RWDai 9179516b19 Label OpenAI JS SDK in usage records 2026-05-18 19:30:19 +08:00
RWDai 37abfe66f0 Label OpenAI JS SDK in user usage 2026-05-18 19:29:59 +08:00
RWDai ae9d4038b1 Prefer typed admin usage client family 2026-05-18 19:29:36 +08:00
RWDai b6f558d10b Project usage client family in Postgres reads 2026-05-18 19:29:10 +08:00
RWDai 6d994917a0 Add usage client family read model field 2026-05-18 19:28:36 +08:00
fawney19 b99f43783c Merge remote-tracking branch 'origin/pr-482'
# Conflicts:
#	.github/workflows/release.yml
2026-05-18 18:55:11 +08:00
fawney19 7f76eff827 Merge remote-tracking branch 'origin/pr-484' 2026-05-18 18:01:57 +08:00
fawney19 be939f7e63 Merge remote-tracking branch 'origin/pr-493' 2026-05-18 18:01:43 +08:00
fawney19 a8a87d2b41 Merge remote-tracking branch 'origin/pr-494' 2026-05-18 18:00:47 +08:00
RWDai 0a26accca4 Merge remote-tracking branch 'upstream/main' into feat/356-usage-record-columns 2026-05-18 17:49:35 +08:00
fawney19 40e925680c Merge remote-tracking branch 'origin/pr-480' 2026-05-18 16:46:35 +08:00
fawney19 f2cdb74ed8 Merge remote-tracking branch 'origin/pr-473'
# Conflicts:
#	frontend/src/features/providers/components/ProviderModelFormDialog.vue
2026-05-18 16:46:22 +08:00
Codex 7ac8159728 fix: use Vertex Model Garden models endpoint 2026-05-18 08:17:09 +00:00
fawney19 3f0fd15395 Merge remote-tracking branch 'origin/pr-491' 2026-05-18 16:14:07 +08:00
fawney19 90dbc279fd Merge remote-tracking branch 'origin/pr-476' 2026-05-18 16:13:45 +08:00
fawney19 3723f165bc Merge remote-tracking branch 'origin/pr-496' 2026-05-18 16:13:03 +08:00
fawney19 3bffecddf2 fix: route access log sanitizer through gateway api 2026-05-18 15:47:02 +08:00
fawney19 a818af7833 Merge remote-tracking branch 'origin/pr-492' 2026-05-18 14:52:29 +08:00
fawney19 187b3a08fb Merge remote-tracking branch 'origin/pr-495' 2026-05-18 14:51:50 +08:00
fawney19 2405ccc0f2 Merge remote-tracking branch 'origin/pr-490' 2026-05-18 14:51:14 +08:00
fawney19 0fcfcae9c8 Merge remote-tracking branch 'origin/pr-486' 2026-05-18 14:50:48 +08:00
ZheFox 2de0cbbafc Merge branch 'fawney19:main' into main 2026-05-18 13:13:37 +08:00
ZheFox a4012ad353 Merge upstream/main 2026-05-18 13:11:11 +08:00
fawney19 e4315fbbf0 fix: refine frontend admin and auth UI 2026-05-18 12:41:57 +08:00
ZheFox a10c02ef63 feat(billing): add image output range pricing support 2026-05-18 11:52:01 +08:00
MMEXA 66f154a251 fix(gateway): avoid low OpenAI-compatible test token cap 2026-05-18 03:06:46 +00:00
fawney19 92813e6122 feat: add routing profile scheduling policies 2026-05-18 11:03:49 +08:00
MMEXA f50f26e599 fix(gateway): cover Google OpenAI-compatible roots 2026-05-18 02:15:23 +00:00
ZheFox a3094fda53 fix(gateway): ignore OpenAI tools for image intent routing 2026-05-18 09:54:10 +08:00
MMEXA b004a02e4a fix(gateway): harden Gemini endpoint routing 2026-05-18 00:53:34 +00:00
MMEXA 9586f5158e Fix fixed-provider endpoint key counts 2026-05-17 20:55:36 +00:00
ZheFox f5ace4fd6d fix(frontend): stabilize image generation overrides in model forms 2026-05-18 03:39:55 +08:00
ZheFox a05ae94cea fix(frontend): update checkbox bindings to checked events 2026-05-18 03:27:31 +08:00
ZheFox dff17b6cb1 feat(billing): support image output pricing in model forms and details 2026-05-18 03:19:58 +08:00
ZheFox 0b2a8fafce feat(billing): add image output pricing and usage tracking 2026-05-18 02:49:56 +08:00
ZheFox 691ccaaa04 fix(usage): track OpenAI image SSE completion and usage estimates 2026-05-18 01:32:45 +08:00
RWDai 26900c8c9e Show only client family in usage client column 2026-05-18 00:46:41 +08:00
MMEXA 226ce45d0d fix: pass explicit local build version 2026-05-17 16:38:22 +00:00
Kayphoon ae472d6744 fix(data): require provider id for provider usage aggregation 2026-05-18 00:36:29 +08:00
MMEXA 89d08d9953 fix: align provider model fetch state 2026-05-17 16:17:47 +00:00
ZheFox c024c782e4 feat(billing): add image quality pricing and usage tracking 2026-05-18 00:11:30 +08:00
MMEXA 84fc1e35e2 fix(frontend): support login autofill and reliable redirect 2026-05-17 16:03:57 +00:00
MMEXA 995be3781a fix(frontend): align usage APIs with Rust routes 2026-05-17 15:37:52 +00:00
MMEXA ed570155a1 fix(gateway): redact credential query values in access logs 2026-05-17 15:26:56 +00:00
ZheFox 680b617b00 feat(gateway): route OpenAI image streams through chat bridge 2026-05-17 23:09:50 +08:00
RWDai 0a62e4bc77 Remove usage request path column 2026-05-17 23:05:02 +08:00
RWDai 96d40dd21d Infer usage client family from User-Agent 2026-05-17 23:04:31 +08:00
MMEXA ff83b54c3b fix(gateway): reject empty Gemini success responses 2026-05-17 14:55:33 +00:00
MMEXA 81ff375bfd fix(gateway): route Gemini embedding batches correctly 2026-05-17 14:24:32 +00:00
dalamudx b0fc6e68ef fix: add icon_url to mysql generated baseline 2026-05-17 22:00:28 +08:00
dalamudx e1a73be2e1 fix: update schema baselines, logical schema, and snapshot cutoff for icon_url 2026-05-17 21:52:36 +08:00
dalamudx cf2c74e8e8 fix: add new migration version to test whitelist 2026-05-17 21:42:56 +08:00
dalamudx 2cb01f7a69 fix: add missing icon_url arg in test helper 2026-05-17 21:40:54 +08:00
dalamudx 48deff15c4 fix(oauth): fix login failures and add provider icon_url config
- Fix FIND_OAUTH_LINKED_USER_SQL missing allowed_providers_mode columns
- Fix TOUCH_OAUTH_LINK_SQL json/jsonb type mismatch in COALESCE
- Add icon_url field to OAuth provider config (DB, API, frontend)
- Fix admin OAuth test: accept 404 as reachable, use system proxy
2026-05-17 21:33:02 +08:00
ZheFox d6c8c14de7 feat(gateway): route OpenAI image intents through image bridge 2026-05-17 21:19:17 +08:00
RWDai b2266b588e Preserve usage origin metadata in Postgres lists 2026-05-17 21:09:59 +08:00
RWDai fcaafb3939 chore(ci): retrigger flaky sqlite smoke 2026-05-17 20:38:55 +08:00
fawney19 7d569127ae Fix active probe pool fallback tracing 2026-05-17 20:34:06 +08:00
RWDai 981020a5ab fix(ci): apply rustfmt to usage metadata handlers 2026-05-17 20:28:08 +08:00
ZheFox d9c8119bda fix(routing): match provider model names in admin routing counts 2026-05-17 18:59:41 +08:00
ZheFox 485d166912 fix(provider): count inherited endpoint formats for model tests 2026-05-17 18:04:16 +08:00
Kayphoon 5060532c51 fix: package release sqlite compose template 2026-05-17 16:33:35 +08:00
Kayphoon f29cca72ba refactor(data): limit query abstraction to postgres and sqlite 2026-05-17 15:40:44 +08:00
Kayphoon 77640d51a6 refactor(data): add select query abstraction 2026-05-17 15:04:35 +08:00
HsungKayphoon f0a6fffa87 refactor(data): introduce simple query helper 2026-05-17 14:07:23 +08:00
Entropy.Xu 1b24e1c22a fix(wallet): 修复额度耗尽后仍可消费 2026-05-17 11:49:08 +08:00
ZheFox ab0d766f47 fix(usage): stop inferring cache reads from prompt_cache_key 2026-05-17 03:04:33 +08:00
ZheFox 41ffb18604 fix(billing): avoid double counting cache read in OpenAI cache hit context 2026-05-17 02:40:01 +08:00
ZheFox b903f8ff7d fix(usage): estimate cache read tokens for cancelled requests 2026-05-17 02:13:13 +08:00
ZheFox 0483d001b4 fix(usage): bill cancelled terminal usage and preserve total token estimates 2026-05-17 01:11:42 +08:00
HsungKayphoon d290a1fdb9 fix: satisfy rust 1.95 clippy 2026-05-17 00:00:46 +08:00
ZheFox 2803e9317d fix(usage): bill cancelled terminal usage and preserve terminal telemetry 2026-05-16 23:43:40 +08:00
HsungKayphoon c5e26a1ed6 fix: align sqlite repositories with postgres behavior 2026-05-16 23:43:40 +08:00
fawney19 a2f91b4108 Cancel upstream stream on downstream disconnect 2026-05-16 22:04:11 +08:00
fawney19 664bd98056 Merge pull request #479 from Entropy-Xu/codex/fix-balance-cost-estimate
fix(gateway): 修复额度预检误判余额不足
2026-05-16 21:23:23 +08:00
mayrain 5bf236957e feat(grok): add runtime image surfaces 2026-05-16 21:15:38 +08:00
mayrain 936e1ae37b feat(grok): add admin oauth and quota support 2026-05-16 21:15:38 +08:00
mayrain cbfe1d378f feat(grok): add provider pool and transport support 2026-05-16 21:15:38 +08:00
mayrain e5f1f52759 feat(model-test): wire image previews into provider tests 2026-05-16 21:15:27 +08:00
mayrain edacc5a7d0 feat(model-test): add image-aware request helpers 2026-05-16 21:15:27 +08:00
fawney19 ed9267562b Adjust provider detail key pagination 2026-05-16 21:08:25 +08:00
Entropy.Xu bddae47454 fix(gateway): 修复额度预检误判余额不足 2026-05-16 20:45:40 +08:00
fawney19 3a5922d4ee fix: use codex quota refresh for account checks 2026-05-16 20:16:23 +08:00
fawney19 56994d4c29 fix(frontend): relax system import limits 2026-05-16 19:00:20 +08:00
Entropy.Xu 973eb1a614 feat(referrals): 添加邀请返利和注册确认功能 2026-05-16 17:41:52 +08:00
HsungKayphoon f9f1fa928a refactor: drive pg sqlite copy from target schema 2026-05-16 17:29:14 +08:00
fawney19 328ac721ce Merge pull request #472 from Kayphoon/codex/manual-usage-cleanup-origin-main
feat: add scoped manual usage cleanup
2026-05-16 16:25:40 +08:00
HsungKayphoon 527feb69db fix: cover portable migration tables in logical schema 2026-05-16 15:58:41 +08:00
RWDai ba661f1b3c Add usage record column controls 2026-05-16 15:55:38 +08:00
RWDai 4f584a71df Add usage metadata frontend plumbing 2026-05-16 15:55:38 +08:00
RWDai 97cd92a1a2 Expose usage record metadata fields 2026-05-16 15:55:38 +08:00
RWDai df7b2824a6 Persist client family usage metadata 2026-05-16 15:55:38 +08:00
RWDai 03ba1f94d7 Show execution failure reasons in request details 2026-05-16 15:42:13 +08:00
RWDai 6dd5d2fe14 Add failure notice resolver for usage records 2026-05-16 15:42:13 +08:00
RWDai a2649718ea Expose scheduling failure details in usage payload 2026-05-16 15:42:13 +08:00
RWDai 9325c2ad9d fix(providers): remove unreachable manual model add flow 2026-05-16 15:35:26 +08:00
RWDai 590151f40b feat(models): allow manual global model creation 2026-05-16 15:27:34 +08:00
HsungKayphoon 4b12ec8913 feat: add postgres to single-node migration 2026-05-16 15:26:43 +08:00
HsungKayphoon 74a1e5ad7d feat: add scoped manual usage cleanup 2026-05-16 15:23:44 +08:00
fawney19 75b7319465 Merge pull request #471 from Kayphoon/fix/usage-provider-id-null 2026-05-16 15:19:30 +08:00
fawney19 6a608b8e3f fix: recover finalized usage provider links safely 2026-05-16 15:15:57 +08:00
beilo 2ca4b486ec fix: auto-remove invalid oauth pool keys 2026-05-16 15:08:16 +08:00
fawney19 c2cdcefdc8 fix(public): 恢复 support.rs 的 system_config_bool 引入 2026-05-16 14:28:19 +08:00
fawney19 893ac18d60 Merge PR #469: 修复用户可见性、额度、验证与 Codex 探测 2026-05-16 14:28:19 +08:00
fawney19 74abb50bdc fix(public): keep original GitHub links visible
(cherry picked from commit 7c93552697c9c35b77df35e117900ff8e9b62994)
2026-05-16 13:50:46 +08:00
Kayphoon f817f856c8 fix: allow upsert to backfill NULL provider link fields after billing finalizes
The ON CONFLICT update guard for provider_id, provider_endpoint_id, and
provider_api_key_id previously required billing_status = 'pending'. Once a
usage row left pending state with these fields still NULL, subsequent upserts
could never fill them. Add an OR IS NULL clause so missing provider links are
always recoverable regardless of billing status.
2026-05-16 13:11:59 +08:00
fawney19 3a23eaa572 Merge pull request #470 from yao177/fix/provider-keep-priority-on-conversion-load-balance
fix: preserve conversion priority beneath scheduler modes
2026-05-16 12:56:57 +08:00
fawney19 a7fdce493b feat: paginate provider keys from backend 2026-05-16 12:52:49 +08:00
yao177 232976c14a fix: keep conversion priority below load balance 2026-05-16 04:41:07 +00:00
yao177 dc1009798d test: cover cache affinity conversion priority ordering 2026-05-16 04:32:56 +00:00
fawney19 b6d74249a4 fix(public): keep original GitHub links visible
(cherry picked from commit 7c93552697c9c35b77df35e117900ff8e9b62994)
2026-05-16 11:38:49 +08:00
fawney19 f72ab383c9 Refine OAuth auto cleanup conditions
(cherry picked from commit 78826eae47ab18ace6931dd190a41070416b5e10)
2026-05-16 11:33:45 +08:00
fawney19 f59cf1090d feat(admin): unify system data management and aggregate import/export 2026-05-16 02:16:53 +08:00
Entropy.Xu 1a50c5e112 merge: 同步主线并解决用户侧验证冲突 2026-05-16 01:25:42 +08:00
fawney19 48da062251 Merge pull request #468 from fawney19/revert-pr-466
Revert PR #466
2026-05-16 01:01:44 +08:00
Entropy.Xu bbd3c30b0e fix(public): 修复用户可见性、额度、验证与 Codex 探测 2026-05-16 00:51:44 +08:00
fawney19 d6c320bf06 Revert "Merge remote-tracking branch 'origin/pr/466'"
This reverts commit 0e0a24862f, reversing
changes made to b09fd48eee.
2026-05-16 00:50:00 +08:00
fawney19 d53546d56f Make pool probing request-driven 2026-05-16 00:18:47 +08:00
fawney19 43d891bee1 Remove legacy Python tests 2026-05-16 00:06:21 +08:00
fawney19 0e0a24862f Merge remote-tracking branch 'origin/pr/466' 2026-05-15 22:49:35 +08:00
ZheFox 2345df0e38 fix(usage): bill cancelled terminal usage 2026-05-15 22:38:34 +08:00
fawney19 b09fd48eee Merge remote-tracking branch 'origin/pr/462' 2026-05-15 22:28:40 +08:00
fawney19 c916e76bd2 Merge branch 'pr-461'
# Conflicts:
#	.github/workflows/rust-ci.yml
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-15 21:52:37 +08:00
fawney19 8eb4c029b2 Merge pull request #467 from AAEE86/main
fix(usage): prevent detail view from overriding active request status
2026-05-15 21:08:48 +08:00
fawney19 87e44479cc Add proxy tunnel diagnostics and default logging 2026-05-15 19:43:59 +08:00
AAEE86 9c7757f801 fix(usage): prevent detail view from overriding active request status
- Keep pending/streaming lifecycle status authoritative for active requests
- Prevent detail status code or trace state from misclassifying in-flight requests as stream/failed
- Add regression coverage for status resolution and timeline state emission
2026-05-15 19:04:50 +08:00
fawney19 1503986d40 Fix compose installer defaults 2026-05-15 18:43:47 +08:00
fawney19 0bd3e2fa88 Merge remote-tracking branch 'origin/main' 2026-05-15 18:13:26 +08:00
fawney19 0f0b9a6118 chore: add db maintenance make targets 2026-05-15 18:10:49 +08:00
fawney19 e4f427f921 Merge remote-tracking branch 'entropy-xu/payment-billing-plans'
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	frontend/src/views/admin/Users.vue
2026-05-15 16:37:17 +08:00
fawney19 2006a3e678 Merge pull request #464 from RWDai/fix/issue-455-codex-compact-include
fix: strip include from codex compact requests
2026-05-15 16:10:33 +08:00
fawney19 38240f77ff chore: replace dev script with make targets 2026-05-15 16:09:11 +08:00
RWDai 6014925e60 style: format codex compact regression tests 2026-05-15 14:40:27 +08:00
RWDai 1dc9b505e4 fix: strip include from codex compact requests 2026-05-15 14:20:16 +08:00
fawney19 b0ba1f250d Merge pull request #463 from AAEE86/main
chore(deps): update npm lockfiles
2026-05-15 14:13:14 +08:00
fawney19 2e9feaa60a Add local dev.sh script 2026-05-15 13:51:52 +08:00
AAEE86 8538364930 chore(deps): update npm lockfiles 2026-05-15 13:46:32 +08:00
fawney19 fb2662b877 Merge pull request #460 from RWDai/manual-provider-model-save-20260515
Allow manual provider model save without online discovery
2026-05-15 13:33:32 +08:00
fawney19 170218bb26 Merge pull request #458 from RWDai/opencode/sunny-orchid
Add one-click proxy node installation
2026-05-15 12:49:08 +08:00
fawney19 582504d11a Allow deploy to pull missing images 2026-05-15 11:29:16 +08:00
RWDai 88d8a8b79f Pin Rust CI component installs to 1.95.0 2026-05-15 10:14:50 +08:00
RWDai 8e4fc40be5 Refresh Rust CI for audit admin enum fix 2026-05-15 10:02:01 +08:00
fawney19 01a982bdf6 fix: preserve codex include fields 2026-05-15 03:25:06 +08:00
fawney19 daf33a82a6 deploy: restore local build and sqlite compose 2026-05-15 03:17:14 +08:00
fawney19 cc5a44e373 Merge branch 'pr-453' 2026-05-15 02:25:39 +08:00
fawney19 8e0f8003a9 fix: simplify account self-check config 2026-05-15 02:21:11 +08:00
fawney19 54a8312e46 feat: add adaptive pool metrics and self-check 2026-05-15 02:21:11 +08:00
Entropy.Xu 85f48123b6 feat(auth): 添加 Turnstile 注册防护 2026-05-15 01:54:20 +08:00
RWDai fdea51c7b1 Simplify proxy install command copy 2026-05-15 01:21:31 +08:00
RWDai 870bb19798 Update proxy installer branch references 2026-05-15 01:21:08 +08:00
RWDai dbec85344d Fix Rust CI toolchain setup 2026-05-15 01:20:36 +08:00
RWDai 4db01244ec Update Postgres bootstrap for audit admin role 2026-05-15 01:20:29 +08:00
RWDai 6021110fb2 Add audit admin Postgres userrole migration 2026-05-15 01:20:29 +08:00
RWDai 1216fa940e Allow manual provider model save without online discovery 2026-05-15 00:51:30 +08:00
RWDai bcf4adf944 Merge branch 'main' into opencode/sunny-orchid
# Conflicts:
#	README.md
2026-05-14 20:22:36 +08:00
RWDai 07ea745f56 Document proxy one-click installation 2026-05-14 19:35:41 +08:00
RWDai 3e122c84ef Add proxy node script install UI 2026-05-14 19:35:16 +08:00
RWDai 98a54b4633 Add proxy install session client API 2026-05-14 19:34:56 +08:00
RWDai 2db85b1c53 Add proxy one-click installer scripts 2026-05-14 19:34:33 +08:00
RWDai 91545cf906 Add admin proxy install session creation 2026-05-14 19:34:09 +08:00
RWDai 5a15822ce0 Add public proxy install session delivery 2026-05-14 19:33:47 +08:00
ZheFox eef21b6c34 Merge branch 'fawney19:main' into main 2026-05-14 17:47:47 +08:00
zhefox 498b3b1226 fix(auth): correct postgres api key create field ordering 2026-05-14 17:41:13 +08:00
zhefox 3b77686cee fix(auth): cast standalone api key expires_at update to timestamptz 2026-05-14 16:28:38 +08:00
fawney19 bf511f9f8c Merge pull request #449 from RWDai/feat/audit-admin-readonly
Add read-only audit administrator role
2026-05-14 15:51:27 +08:00
ZheFox a0eed2cc51 fix: split Codex chat and responses defaults 2026-05-14 15:33:03 +08:00
fawney19 e61aa46dad deploy: limit installer to supported modes 2026-05-14 15:26:58 +08:00
fawney19 d2831ec6f0 deploy: remove local build compose path 2026-05-14 14:40:25 +08:00
fawney19 a7e71624e3 ci: align release channels and speed rust checks 2026-05-14 14:09:27 +08:00
RWDai bc0017a1b4 Use audit admin permissions in proxy auth 2026-05-14 13:39:57 +08:00
RWDai 4cb7b2d494 Add audit admin internal read permissions 2026-05-14 13:39:57 +08:00
RWDai eb7c8a3ad5 Format audit admin role helpers 2026-05-14 13:39:57 +08:00
RWDai 6e2f90aba8 Keep audit admins on read-only admin views 2026-05-14 13:39:57 +08:00
RWDai 9c59c0e1a2 Add audit role to user management UI 2026-05-14 13:39:57 +08:00
RWDai 6a9a54cad6 Show audit admin labels in shared layouts 2026-05-14 13:39:57 +08:00
RWDai e768145961 Update public auth redirects for audit admins 2026-05-14 13:38:45 +08:00
RWDai a4e040a7d7 Route audit admins into admin console 2026-05-14 13:38:45 +08:00
RWDai e818443841 Add frontend audit admin auth semantics 2026-05-14 13:38:45 +08:00
RWDai 337d0af136 Accept audit role in user administration 2026-05-14 13:38:45 +08:00
RWDai 4d2667764f Enforce read-only admin route permissions 2026-05-14 13:38:45 +08:00
RWDai feb676b66f Allow audit admins through backend admin auth 2026-05-14 13:38:45 +08:00
RWDai 14871c2255 Add audit administrator role helpers 2026-05-14 13:38:45 +08:00
fawney19 48fe52b207 Update guide setup commands 2026-05-14 13:02:25 +08:00
fawney19 509bd30252 Redesign sensitive info protection settings 2026-05-14 11:14:20 +08:00
fawney19 91955ad1e0 Merge remote-tracking branch 'origin/pr/451' into aether-rust-pioneer 2026-05-14 02:10:37 +08:00
fawney19 b41a4a000f Merge remote-tracking branch 'origin/pr/435' into aether-rust-pioneer 2026-05-14 02:02:26 +08:00
fawney19 d29d1cf63b Remove deprecated Python source tree 2026-05-14 01:58:05 +08:00
fawney19 1f8ff7f6d2 Fix PR 434 check failures 2026-05-14 01:51:23 +08:00
fawney19 e251a63cb3 Merge remote-tracking branch 'pr-434/fix-provider-model-test-compat' into aether-rust-pioneer 2026-05-14 01:28:33 +08:00
fawney19 eb654c2fe7 Merge pull request #441 from zhefox/aether-rust-pioneer
fix: add codex reasoning defaults and stream rewrite tests
2026-05-14 00:35:45 +08:00
fawney19 697b6e0653 fix: stabilize openai responses reasoning streams 2026-05-14 00:27:10 +08:00
fawney19 acd44328d6 feat: add version update flow 2026-05-13 22:28:05 +08:00
fawney19 8b813f645f Merge branch 'pr-438' into aether-rust-pioneer 2026-05-13 22:28:05 +08:00
zhefox 2d354fa294 fix: preserve passthrough for same-format stream rewrites 2026-05-13 19:42:22 +08:00
fawney19 cfb22d3f06 Merge pull request #439 from Kayphoon/feat/macos-one-click-install
feat(compose): add solo sqlite compose deployment
2026-05-13 19:03:49 +08:00
fawney19 1a196c8cf2 Merge pull request #443 from AAEE86/rust
feat(admin): add Done-hub provider ops template
2026-05-13 18:42:28 +08:00
fawney19 f847a71747 Merge pull request #448 from RWDai/fix/disable-hidden-user-policies
Disable hidden per-user policy fields
2026-05-13 18:40:46 +08:00
RWDai 87c0a915b8 Align rate limit monitoring test with group policy 2026-05-13 18:27:56 +08:00
Kayphoon 2958041dc7 feat(gateway): add reversible chat pii redaction 2026-05-13 18:25:13 +08:00
fawney19 5d1460e051 refactor: extract provider pool abstractions 2026-05-13 18:19:15 +08:00
RWDai 6a9017bfce Cover disabled user policy API behavior 2026-05-13 17:56:18 +08:00
RWDai a1b0db38f5 Reject hidden user policy payloads 2026-05-13 17:56:18 +08:00
RWDai a99546b390 Disable user policy during auth resolution 2026-05-13 17:56:17 +08:00
zhefox 1adbf23be4 feat(ai-formats): add reasoning summary boundaries for streams 2026-05-13 17:41:34 +08:00
AAEE86 afbb656510 feat(admin): add Done-hub provider ops template
- 新增 Done-hub Cookie 认证架构
- 使用 /api/user/profile 查询余额,按 quota / 500000 换算
- 补充余额解析、认证头和校验相关测试
2026-05-13 16:10:59 +08:00
zhefox 1726df1169 fix: add codex reasoning defaults and stream rewrite tests 2026-05-13 14:34:16 +08:00
Kayphoon d69d862034 feat(compose): add solo sqlite compose deployment 2026-05-13 11:21:35 +08:00
RWDai a03cab4a09 Show update status on admin dashboard 2026-05-13 10:28:18 +08:00
RWDai c90ed14a24 Fetch Aether releases for update checks 2026-05-13 10:27:45 +08:00
RWDai e00df5f7a2 Add admin update payload builder 2026-05-13 10:27:21 +08:00
fawney19 3c2497f019 Fix admin pool sorting and OAuth refresh 2026-05-13 09:16:52 +08:00
Entropy.Xu 3fb007a56d Merge remote-tracking branch 'origin/aether-rust-pioneer' into payment-billing-plans
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-13 01:29:18 +08:00
Entropy.Xu 10285c5eb9 feat: add payment gateway and billing plans 2026-05-13 01:18:38 +08:00
Kayphoon 15800d7a80 feat(admin): manual request-records cleanup with typed confirmation 2026-05-13 00:36:43 +08:00
fawney19 4387a9cdd5 Sync Cargo.lock for release build 2026-05-13 00:07:55 +08:00
fawney19 8714d93d4b Stabilize Codex OAuth import tests 2026-05-12 23:36:43 +08:00
fawney19 68256ece2d Fix Kiro manual OAuth refresh test stack 2026-05-12 23:09:09 +08:00
fawney19 339808d55b 更新aether-proxy版本号 2026-05-12 22:46:49 +08:00
fawney19 e7471f44b0 fix provider oauth endpoint reconciliation 2026-05-12 22:46:20 +08:00
fawney19 d1a47c068e feat: update gateway pool and usage flows 2026-05-12 21:05:11 +08:00
mayrain 43c476d54a fix: refine provider model test dialog 2026-05-12 20:11:37 +08:00
mayrain 9f26383de5 fix: route provider model tests through candidates 2026-05-12 20:11:16 +08:00
mayrain 8f082674d7 fix: align embedding provider request formats 2026-05-12 20:10:52 +08:00
mayrain fca3f24d91 fix: preserve legacy admin config imports 2026-05-12 20:10:27 +08:00
fawney19 38012c62ff Allow full management tokens to access token management 2026-05-12 19:53:31 +08:00
fawney19 63149fe281 Add tunnel overload protection 2026-05-12 18:36:41 +08:00
fawney19 5509f70ad4 Bump proxy version to 0.3.9 2026-05-12 18:36:41 +08:00
github-actions[bot] 110cb147d1 chore(proxy): update download links for proxy-v0.3.9 2026-05-12 09:04:17 +00:00
fawney19 92e4066977 Merge pull request #432 from RWDai/fix/cli-install-copy
Add admin API key CLI install sessions
2026-05-12 16:50:24 +08:00
fawney19 ba5e802174 Merge pull request #433 from Kayphoon/feat/macos-one-click-install
feat(install): add macOS native one-click install with launchd
2026-05-12 16:49:50 +08:00
fawney19 4b2507b155 Fix score popover duplication 2026-05-12 16:04:00 +08:00
Kayphoon 6d2fcf12cd feat(install): add macOS native one-click install with launchd
- Release workflow builds macos-amd64/macos-arm64 tarballs on native runners
- install.sh detects Darwin, downloads macos-* assets, installs LaunchDaemon
- Dedicated _aether service account (dscl), env root:_aether 0640
- Launchd stdout/stderr in /var/log/aether (root-owned dir, service-writable files)
- Wrapper script parses env literally without shell expansion
- Auto-prune old releases (default keep 3)
- README documents macOS launchd commands
2026-05-12 15:36:29 +08:00
RWDai f60fc1cd7a fix: improve api key install copy flow 2026-05-12 15:34:16 +08:00
RWDai 9fc270fe69 feat: add admin api key install dialog 2026-05-12 15:34:08 +08:00
RWDai a8ff050518 fix: narrow api key install session types 2026-05-12 15:33:53 +08:00
RWDai a7bab0ebd2 feat: add admin api key install sessions 2026-05-12 15:33:45 +08:00
RWDai 8eda0932b0 fix: preserve install base URLs 2026-05-12 15:33:30 +08:00
fawney19 4e563e3385 Adjust proxy tunnel sizing defaults 2026-05-12 15:30:55 +08:00
fawney19 9c05b5f4e0 Unify pool score display as account health 2026-05-12 15:27:21 +08:00
fawney19 fa73655134 refactor: isolate dispatch scheduling core 2026-05-12 13:15:41 +08:00
fawney19 81ee27cdea Merge remote-tracking branch 'origin/codex/pool-member-scores' into aether-rust-pioneer 2026-05-12 09:17:25 +08:00
fawney19 fad28eee2c Improve pool score probing rules 2026-05-12 09:08:10 +08:00
fawney19 c578689356 Fix pool score CI regressions 2026-05-12 02:17:19 +08:00
fawney19 b9e62d1667 Implement generic pool member scoring and probing 2026-05-12 01:46:22 +08:00
fawney19 09146f8cdd fix: tighten model candidate matching scopes 2026-05-12 00:52:19 +08:00
fawney19 0fa97595bf Fix reasoning model directive response identity 2026-05-11 23:03:11 +08:00
fawney19 7ae38b6c43 Merge pull request #429 from AAEE86/rust
fix(usage): 统一用户排行榜 Token 统计口径
2026-05-11 22:46:56 +08:00
AAEE86 bfbf7a4663 fix(usage): 统一用户排行榜 Token 统计口径
- 将用户排行榜 Tokens 调整为与仪表盘今日 Token 一致
- 统一 effective input、cache creation fallback、cache read 计算规则
- 补齐 Postgres 聚合、admin 内存构建和内存仓库回退路径
- 增加缓存命中场景的统计口径测试
2026-05-11 22:34:37 +08:00
fawney19 cb0ccb9cdb fix postgres user role enum casts in migrations 2026-05-11 20:31:47 +08:00
fawney19 ce70780851 Fix proxy node uptime bucket rendering 2026-05-11 20:22:40 +08:00
fawney19 30b18d3310 fix: use gateway healthcheck flag in compose 2026-05-11 19:21:46 +08:00
fawney19 1d33e2c51b Fix Gemini model denial test fixture 2026-05-11 18:40:28 +08:00
fawney19 fed676f54f fix: exempt admins from default user group limits 2026-05-11 18:20:01 +08:00
fawney19 9bed5e9f83 fix: use intersection for user group policies 2026-05-11 17:00:07 +08:00
fawney19 e16a225eb3 Merge pull request #428 from AAEE86/rust
fix(gateway): 修复正则模型映射未生效到出站请求
2026-05-11 16:04:56 +08:00
AAEE86 67ca47afd4 fix(gateway): 修复正则模型映射未生效到出站请求
修复 key allowed_models 通过 global_model_mappings 正则命中时,
候选选择仍使用 provider model mapping 作为出站模型的问题。

正则命中后将 allowed model 写入 selected_provider_model_name,
确保最终 execution runtime 请求体中的 model 使用映射后的模型。

补充三层回归测试:
- scheduler-core 正则映射解析
- gateway candidate/data 候选选择输出
- gateway ai_execute 出站请求 model 捕获
2026-05-11 15:58:13 +08:00
fawney19 9057537ab8 fix: preserve model associations on refresh 2026-05-11 14:52:50 +08:00
fawney19 247ea9d1bd Restrict scheduler affinity to cache affinity mode 2026-05-11 14:06:49 +08:00
fawney19 e91c874863 fix(migrations): explicitly set timestamps in system_configs insert
老库的 created_at/updated_at 没有 DEFAULT now(),依赖默认值会写入 NULL
触发 NOT NULL 约束失败,改为显式传入 now() 与 sqlite/mysql 版本保持一致
2026-05-11 03:22:46 +08:00
github-actions[bot] 40470d8ca5 chore(proxy): update download links for proxy-v0.3.8 2026-05-10 19:10:49 +00:00
fawney19 94c0076778 chore(proxy): bump version to 0.3.8 2026-05-11 03:03:04 +08:00
fawney19 b813498e40 feat(proxy): surface node resource diagnostics 2026-05-11 02:59:18 +08:00
fawney19 cc4512fbbb Refine load balance candidate ranking 2026-05-11 02:32:11 +08:00
fawney19 ef4cc55c9a Record per-candidate upstream error bodies 2026-05-11 02:30:45 +08:00
fawney19 e3574e1918 Track scheduler affinity epochs and key sorting 2026-05-11 01:45:49 +08:00
fawney19 7b81c77424 Fix regex model mapping direction 2026-05-11 01:43:14 +08:00
fawney19 c9c968c2e9 fix: avoid duplicate user policy migration version 2026-05-11 00:39:34 +08:00
fawney19 e3f8fef30c Merge remote-tracking branch 'origin/pr/425' into aether-rust-pioneer 2026-05-11 00:19:50 +08:00
fawney19 ceda0635e4 Merge remote-tracking branch 'origin/pr/424' into aether-rust-pioneer
# Conflicts:
#	crates/aether-provider-transport/src/vertex/auth.rs
#	crates/aether-provider-transport/src/vertex/mod.rs
#	crates/aether-provider-transport/src/vertex/policy.rs
2026-05-11 00:19:04 +08:00
fawney19 bacb14e5f0 refactor: lazy pool key scheduling 2026-05-11 00:12:05 +08:00
Entropy.Xu 9e705ff603 Fix legacy user policy modes for empty lists 2026-05-10 19:35:09 +08:00
fawney19 1a0f1a7b72 Merge branch 'codex/pr-416-420-integration' into aether-rust-pioneer 2026-05-10 19:22:28 +08:00
fawney19 3201851667 Merge remote-tracking branch 'origin/pr/416' into codex/pr-416-420-integration 2026-05-10 19:13:42 +08:00
Codex 75df21932a 修复 Gemini 工具结果透传 Vertex 格式 2026-05-10 19:08:35 +08:00
Codex fb96771b56 修复 Vertex AI 服务账号访问 2026-05-10 19:08:27 +08:00
fawney19 c4b484fb43 fix: satisfy vertex transport lint checks 2026-05-10 19:07:51 +08:00
fawney19 37fb79fb87 fix: drop stray auth modules migration 2026-05-10 18:57:58 +08:00
fawney19 f03039d846 Merge remote-tracking branch 'origin/pr/420' into codex/pr-416-420-integration 2026-05-10 18:44:29 +08:00
fawney19 69476f69b9 Merge remote-tracking branch 'origin/pr/416' into codex/pr-416-420-integration
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-10 18:44:19 +08:00
Entropy.Xu bb22978574 Merge remote-tracking branch 'upstream/aether-rust-pioneer' into fix-management-token-oauth-jsonb
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-10 18:36:53 +08:00
fawney19 228253c166 Merge pull request #410 from Entropy-Xu/codex/codex-image-progress-heartbeat
Add Codex image progress heartbeat
2026-05-10 17:57:33 +08:00
fawney19 d26321006b Merge remote-tracking branch 'origin/aether-rust-pioneer' into aether-rust-pioneer 2026-05-10 17:48:54 +08:00
fawney19 377dd52805 style: polish multi select dropdown 2026-05-10 17:40:32 +08:00
fawney19 d246f6b42c fix: align user group access controls 2026-05-10 17:28:23 +08:00
Entropy.Xu 59d16ebb4b Merge remote-tracking branch 'upstream/aether-rust-pioneer' into fix-management-token-oauth-jsonb
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-10 16:21:09 +08:00
Entropy.Xu 948a173f39 fix: avoid management token migration version collision 2026-05-10 16:16:11 +08:00
Entropy.Xu 56857280d3 fix: tolerate legacy management token json columns 2026-05-10 15:59:13 +08:00
Codex 7e804c408f 修复 Vertex AI 服务账号访问 2026-05-10 15:51:47 +08:00
Entropy.Xu 5268f62a64 feat: add sync image heartbeat toggle 2026-05-10 12:40:52 +08:00
github-actions[bot] 7f101431c5 chore(proxy): update download links for proxy-v0.3.7 2026-05-10 03:27:15 +00:00
fawney19 a8ac944959 Merge remote-tracking branch 'entropy-xu/codex/user-groups-default-permissions' into aether-rust-pioneer 2026-05-10 11:26:29 +08:00
fawney19 e8259a76f0 chore(proxy): bump version to 0.3.7 2026-05-10 11:19:26 +08:00
fawney19 3983b5a5cf Merge pull request #419 from wendaochangsheng/aether-rust-pioneer
修复 Vertex AI 服务账号 JSON 导入入口
2026-05-10 09:27:22 +08:00
fawney19 79b3a76dc1 Merge pull request #411 from buniakinazach-dev/feat/codex-spark-quota
新增 Codex Spark 额度展示
2026-05-10 09:25:44 +08:00
fawney19 c2bf17b4dd Fix Codex Spark quota formatting 2026-05-10 09:14:19 +08:00
Codex be3afbd279 修复 Vertex AI 服务账号 JSON 导入入口 2026-05-10 04:21:29 +08:00
fawney19 18690ceed2 Merge pull request #414 from stabey/fix/claude-tool-results-openai-chat
fix: preserve Claude tool results in OpenAI chat conversion
2026-05-10 02:50:36 +08:00
fawney19 0f42a6ed82 Fix Codex image progress heartbeat merge regressions 2026-05-10 02:10:23 +08:00
Entropy.Xu 545299fc62 fix: align management token oauth permissions and jsonb schema 2026-05-10 01:47:48 +08:00
fawney19 3c1456706a Merge pull request #405 from Entropy-Xu/codex/async-cleanup-records
Add async request body cleanup records
2026-05-10 01:12:12 +08:00
fawney19 a81053e6ff fix(admin): run destructive purges as cleanup tasks 2026-05-10 00:41:21 +08:00
fawney19 391c2fbe5d Merge remote-tracking branch 'origin/aether-rust-pioneer' into codex/async-cleanup-records
# Conflicts:
#	apps/aether-gateway/src/maintenance/mod.rs
#	apps/aether-gateway/src/maintenance/runtime/runners.rs
2026-05-10 00:28:39 +08:00
Entropy.Xu 121bdbd614 fix(data): avoid duplicate user group migration version 2026-05-09 22:26:31 +08:00
stabey dcfdba0a97 fix: preserve claude tool results in openai chat conversion
Preserve all Claude tool_result blocks when emitting OpenAI Chat messages, including multimodal image/file content and is_error markers.
2026-05-09 22:16:27 +08:00
Entropy.Xu a68c690874 Merge remote-tracking branch 'upstream/aether-rust-pioneer' into codex/user-groups-default-permissions 2026-05-09 22:04:09 +08:00
Entropy.Xu 3a814f3d1f feat: add user groups and inherited access policies 2026-05-09 21:47:33 +08:00
fawney19 209322b499 feat(gateway): unify background task runtime and storage 2026-05-09 21:19:29 +08:00
fawney19 4a64d078f3 Merge pull request #407 from stabey/pr/provider-stream-policy
fix: enforce provider upstream stream policy
2026-05-09 13:43:36 +08:00
stabey 5f4fa4ce1f fix: preserve upstream stream accept negotiation 2026-05-09 12:54:47 +08:00
stabey 7e5a08e09d fix: enforce provider upstream stream policy 2026-05-09 12:30:56 +08:00
fawney19 8958bf5e08 Merge pull request #409 from RWDai/feat/issue-373-cli-install
feat: add API key CLI install flow
2026-05-09 12:06:58 +08:00
fawney19 8b67120964 Merge pull request #402 from AAEE86/rust
feat(model-fetch): fetch Kiro models from upstream
2026-05-09 12:03:15 +08:00
fawney19 79d07ac79a Improve request trace upstream diagnostics 2026-05-09 10:46:04 +08:00
Alice 757c264e3e 新增 Codex Spark 额度展示
解析 GPT-5.3-Codex-Spark 额度窗口,并在号池页面和提供商账号抽屉中展示 Spark 周额度与 5H 额度。Spark 额度仅用于展示,不影响普通 Codex 周额度和 5H 额度的调度与筛选逻辑。
2026-05-09 10:44:45 +08:00
RWDai a72bf19454 fix(users): buffer API key install session body 2026-05-09 10:29:36 +08:00
Entropy.Xu eda3738d59 Merge branch 'aether-rust-pioneer' of https://github.com/fawney19/Aether into codex/codex-image-progress-heartbeat
# Conflicts:
#	frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts
2026-05-09 01:26:44 +08:00
Entropy.Xu 3b4f27f767 Add Codex image progress heartbeat 2026-05-09 01:21:26 +08:00
fawney19 4cf0de681a feat(proxy): add node metrics expansion panel 2026-05-08 23:20:50 +08:00
RWDai 2c865ede35 feat(users): add CLI install modal 2026-05-08 23:16:16 +08:00
RWDai 46f44f4ea7 feat(users): add install session client 2026-05-08 23:16:16 +08:00
RWDai da46eb4791 feat(users): add API key install sessions 2026-05-08 23:16:16 +08:00
fawney19 e21fb58479 chore(proxy): guard metrics retention cleanup 2026-05-08 22:57:00 +08:00
fawney19 a703acd1fe feat(proxy): record tunnel stability metrics 2026-05-08 22:03:17 +08:00
fawney19 9a84a6ff6c refactor(ai-formats): group formats by provider
Move protocol/request/response format modules under provider-oriented formats modules and update registry, transport, and architecture paths.
2026-05-08 15:51:14 +08:00
fawney19 84a84e3f31 Merge pull request #404 from RWDai/fix/python-v063-user-import-compat
Preserve Python user import compatibility
2026-05-08 14:20:08 +08:00
Entropy.Xu 3f29335fd6 Add async request body cleanup records 2026-05-08 13:50:18 +08:00
RWDai 141a81d4a3 Preserve Python user import compatibility 2026-05-08 13:37:32 +08:00
AAEE86 2543676437 feat(model-fetch): fetch Kiro models from upstream
- add Kiro ListAvailableModels request planning and headers
- route Kiro model refresh through upstream fetch
- normalize Kiro model payloads and default model metadata
- remove profileArn requirement from ListAvailableModels
2026-05-08 13:24:27 +08:00
fawney19 fa22384f24 Merge remote-tracking branch 'origin/pr/399' into aether-rust-pioneer
# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
2026-05-08 02:52:13 +08:00
fawney19 2a603fa1e4 Merge remote-tracking branch 'origin/pr/397' into aether-rust-pioneer 2026-05-08 02:47:13 +08:00
fawney19 31d142effc Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer 2026-05-08 02:47:07 +08:00
fawney19 66d8e90647 Merge remote-tracking branch 'origin/pr/394' into aether-rust-pioneer 2026-05-08 02:46:35 +08:00
fawney19 080784cd9f Refactor provider transport modules 2026-05-08 02:34:45 +08:00
fawney19 c52ef1993f Improve provider OAuth device flow 2026-05-08 01:33:23 +08:00
fawney19 6247ac3edc refactor: extract runtime state backends 2026-05-08 00:18:12 +08:00
Entropy.Xu cc5cb3475e feat: add management token permissions 2026-05-07 23:48:29 +08:00
RWDai 71742d5ad2 Fix pool cycle usage display 2026-05-07 22:25:52 +08:00
RWDai 1bc0822ce6 fix(users): align batch selection checkbox 2026-05-07 22:22:55 +08:00
RWDai fcefa4d198 feat(users): refine batch action dialog 2026-05-07 22:22:55 +08:00
RWDai 1e2ff26e86 fix(users): harden batch role and quota updates 2026-05-07 22:22:55 +08:00
Entropy.Xu dd8c2ebec6 fix(admin): repair system maintenance controls
Implement server-side searchable user filtering for usage records, including backend search parameters and a shared frontend selector with loading, empty, and pinned-selected states.

Fix announcement deletion by cascading announcement read rows through a Postgres migration and defensive repository cleanup across supported backends.

Wire admin system purge and cleanup endpoints to real data deletion/maintenance flows, improve DataManagement messages, rebuild stats after stats purge, and add runtime OAuth token refresh maintenance when enabled.

Verified with rust-ci equivalent checks: cargo fmt, split clippy, split cargo tests, SQLite/Postgres/MySQL smoke tests, plus frontend npm ci, build, pages build, type-check, and targeted usage selector tests.
2026-05-07 21:26:40 +08:00
fawney19 6f620d92be Skip release checksum verification in installer 2026-05-07 19:32:39 +08:00
RWDai 61473bfb77 feat(users): wire batch actions into users page 2026-05-07 19:16:49 +08:00
RWDai b7172092e8 feat(users): add batch action dialog 2026-05-07 19:16:49 +08:00
RWDai ea014e0d89 feat(users): add batch API store methods 2026-05-07 19:16:49 +08:00
RWDai 8fa90e8ecf refactor(users): share access control options 2026-05-07 19:16:49 +08:00
RWDai 9eb17eca32 refactor(pool): share batch selection helpers 2026-05-07 19:16:49 +08:00
RWDai 18b247de4c test(users): cover batch admin endpoints 2026-05-07 19:16:49 +08:00
RWDai 94852ce60e feat(users): classify batch admin routes 2026-05-07 19:16:49 +08:00
RWDai 59312ebe73 feat(users): add batch admin handlers 2026-05-07 19:16:49 +08:00
RWDai 738031696b fix(users): thread rate limit presence through gateway 2026-05-07 19:16:49 +08:00
RWDai a44667d2a9 fix(users): preserve nullable rate limit updates 2026-05-07 19:16:49 +08:00
fawney19 b8fc36033b Improve installer language and download source prompts 2026-05-07 18:53:09 +08:00
fawney19 6ef0bd9488 fix(ci): clean stale release drafts before publish 2026-05-07 16:30:39 +08:00
fawney19 f3d9523502 Expose upstream proxy in proxy setup TUI 2026-05-07 16:20:20 +08:00
fawney19 2b439094c5 Fix endpoint condition source handling 2026-05-07 15:56:28 +08:00
fawney19 6317587a6b Merge pull request #391 from RWDai/fix/affinity-list-display
Fix affinity list display
2026-05-07 15:54:53 +08:00
github-actions[bot] 7fdbd0c808 chore(proxy): update download links for proxy-v0.3.6 2026-05-07 07:26:05 +00:00
RWDai 44c2534f46 Fix affinity monitoring CI checks 2026-05-07 15:24:17 +08:00
fawney19 af8f9e9057 chore(proxy): bump version to 0.3.6 2026-05-07 15:18:43 +08:00
RWDai cb9d9944e3 Improve affinity list display 2026-05-07 15:08:30 +08:00
RWDai 2cfeb14d88 Add session-scoped affinity deletion 2026-05-07 15:08:14 +08:00
RWDai d5d93eda11 Fix affinity key parsing and counters 2026-05-07 15:08:07 +08:00
fawney19 9bbdf889d4 feat(proxy): support upstream egress proxy 2026-05-07 15:01:18 +08:00
fawney19 96f9be26da polish usage output rate and installer progress 2026-05-07 13:39:25 +08:00
fawney19 0c03c188f1 fix codex window usage stats 2026-05-07 13:35:25 +08:00
fawney19 64af7b1d8a fix: preserve selected keys for quota refresh 2026-05-07 12:55:39 +08:00
github-actions[bot] a345bb8c0d chore(proxy): update download links for proxy-v0.3.5 2026-05-07 04:01:32 +00:00
fawney19 624733e874 fix(compose): rely on postgres database url fallback 2026-05-07 11:53:25 +08:00
fawney19 fd44906bb7 fix provider pool quota status handling 2026-05-07 11:28:44 +08:00
fawney19 01c9f9be49 ci: rename release workflow 2026-05-07 09:46:35 +08:00
fawney19 3d9f189f0e ci: publish prerelease images to pre tag 2026-05-07 09:12:59 +08:00
fawney19 a83aa928a0 ci: parallelize rust checks and guard prereleases 2026-05-07 03:51:58 +08:00
github-actions[bot] 0386e0426b chore(proxy): update download links for proxy-v0.3.4 2026-05-06 19:35:24 +00:00
fawney19 10e679bb2f Add provider key cycle stats reset handling 2026-05-07 03:23:20 +08:00
fawney19 6c0ac2e8da chore: omit deprecated python quota changes 2026-05-07 01:39:48 +08:00
fawney19 2a124318b9 Merge branch 'pr-390' into aether-rust-pioneer 2026-05-07 01:34:38 +08:00
fawney19 47a755a585 Merge branch 'pr-389' into aether-rust-pioneer 2026-05-07 01:34:30 +08:00
fawney19 40314aa7e5 Merge branch 'pr-388' into aether-rust-pioneer 2026-05-07 01:34:22 +08:00
fawney19 df9d30340d Revert "feat: combine usage quota and pool stats updates"
This reverts commit 1f5b294bd0.
2026-05-07 01:33:40 +08:00
fawney19 1f5b294bd0 feat: combine usage quota and pool stats updates 2026-05-07 01:26:42 +08:00
fawney19 012f8bcdf7 feat: scope model mappings by endpoint 2026-05-07 00:48:15 +08:00
Kayphoon 79cb9b502a feat(pool): add codex cycle stats mode 2026-05-07 00:07:32 +08:00
fawney19 4e9f063385 use original request sources for endpoint conditions 2026-05-06 23:48:31 +08:00
Codex 41f75a3f19 feat: show chatgpt web image quota 2026-05-06 22:58:47 +08:00
fawney19 e2bffbbaca Remove body name style rule support 2026-05-06 22:12:21 +08:00
fawney19 9525a68719 Reduce endpoint rules JSON editor height 2026-05-06 21:45:37 +08:00
fawney19 a6b45702e8 Add JSON editor for endpoint rules 2026-05-06 21:30:13 +08:00
fawney19 7b11d43466 Allow request body model editing 2026-05-06 20:35:49 +08:00
fawney19 4bd49d0d7a Add unified install script and release packaging 2026-05-06 20:03:07 +08:00
mayrain 54afe35fcc feat(usage): show output speed in usage records
Display completed request output speed in the usage records table while keeping active requests focused on first-byte latency and live total duration.

The visible table keeps the compact tps label, while the tooltip expands the same value as tokens/s alongside first-byte, total, and generation durations.

Constraint: Synced against aether-rust-pioneer at 77c04749 with no upstream diff

Confidence: high

Scope-risk: narrow

Tested: npm run test:run -- src/features/usage/components/__tests__/UsageRecordsTable.spec.ts src/features/usage/__tests__/performance.spec.ts

Tested: npm run type-check

Tested: npm run build

Not-tested: Full backend test suite
2026-05-06 17:32:23 +08:00
fawney19 77c0474947 Fix usage records table layout 2026-05-06 16:20:09 +08:00
fawney19 33ed7c0737 Fix admin import export version checks 2026-05-06 16:06:45 +08:00
fawney19 bfda9b3e78 Capture TLS fingerprints in report context 2026-05-06 15:02:44 +08:00
fawney19 621eb55ae1 Merge pull request #387 from wendaochangsheng/codex/chatgpt-web-access-token-import
feat: 支持 ChatGPT Web Access Token 导入
2026-05-06 15:02:32 +08:00
Codex 7eaf3e7d03 feat: support chatgpt web access token import 2026-05-06 14:45:39 +08:00
fawney19 68216bf868 Clean up transport fingerprint configuration
Remove legacy tls_profile handling, keep header fingerprint under transport profiles, and drop the duplicate auth_modules migration.
2026-05-06 13:54:33 +08:00
fawney19 6fbb867f5f Exclude unknown providers from usage aggregations 2026-05-06 13:40:18 +08:00
fawney19 94a6f315ca Merge pull request #385 from Entropy-Xu/codex/chatgpt-web-image-proxy
feat(image): 接入 ChatGPT Web 生图反代
2026-05-06 12:01:21 +08:00
Entropy.Xu 4baee436ba feat(image): 接入 ChatGPT Web 生图反代 2026-05-06 11:52:33 +08:00
fawney19 beee7a76d2 Fix postgres export smoke import compatibility 2026-05-06 09:29:12 +08:00
fawney19 887a58d639 Fix usage provider stats and performance analysis UI 2026-05-06 09:29:12 +08:00
fawney19 110b02a213 Merge pull request #386 from fawney19/codex/pr-376-377-383-384-combined
Combine PR #376 #377 #383 #384
2026-05-06 03:20:05 +08:00
fawney19 6219491389 chore: squash inactive mysql sqlite migrations 2026-05-06 03:09:53 +08:00
fawney19 d7c2232062 fix: reconcile combined usage aggregation helpers 2026-05-06 02:47:52 +08:00
fawney19 2eb4afbec4 Merge remote-tracking branch 'origin/pr/384' into codex/pr-376-377-383-384-combined
# Conflicts:
#	frontend/src/views/admin/PerformanceAnalysis.vue
2026-05-06 02:39:53 +08:00
fawney19 b03509a5f4 Merge remote-tracking branch 'origin/pr/383' into codex/pr-376-377-383-384-combined 2026-05-06 02:37:09 +08:00
fawney19 b8a6feef4a Merge remote-tracking branch 'origin/pr/377' into codex/pr-376-377-383-384-combined
# Conflicts:
#	apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs
#	crates/aether-data/src/lifecycle/backfill.rs
#	crates/aether-data/src/repository/usage/postgres/mod.rs
2026-05-06 02:36:57 +08:00
fawney19 4bf86f85b4 Merge remote-tracking branch 'origin/pr/376' into codex/pr-376-377-383-384-combined 2026-05-06 02:33:48 +08:00
fawney19 be580c35bb chore: resolve pr 377 checks 2026-05-06 02:22:32 +08:00
fawney19 cf27773582 feat: improve admin performance analysis 2026-05-06 02:01:32 +08:00
fawney19 6083461822 Add editable response header rules 2026-05-06 01:29:42 +08:00
fawney19 3264857e4a fix(usage): repair aggregate usage statistics 2026-05-06 01:13:23 +08:00
fawney19 f1358dd845 Merge pull request #375 from RWDai/feat/303-client-aware-scheduler
feat: add session-aware scheduler affinity
2026-05-06 00:21:00 +08:00
fawney19 8043cca126 fix: align postgres core export schema 2026-05-06 00:20:06 +08:00
fawney19 6dd7464793 Merge remote-tracking branch 'origin/aether-rust-pioneer' into pr-375-session-scope 2026-05-05 22:34:47 +08:00
fawney19 79c272f0fd Merge pull request #378 from fawney19/codex/transport-profile-routing
Implement transport profile routing
2026-05-05 22:24:48 +08:00
fawney19 f959f02d40 Implement transport profile routing 2026-05-05 22:21:23 +08:00
fawney19 98421126f2 refactor: carry normalized client session affinity 2026-05-05 21:34:17 +08:00
fawney19 ddf3fb6f63 refactor: introduce client session scope abstraction 2026-05-05 20:58:42 +08:00
fawney19 aacab1a90c Merge remote-tracking branch 'origin/aether-rust-pioneer' into aether-rust-pioneer
# Conflicts:
#	crates/aether-data-contracts/src/repository/usage/mod.rs
#	crates/aether-data/src/repository/global_models/postgres.rs
#	crates/aether-data/src/repository/usage/postgres/mod.rs
2026-05-05 18:53:14 +08:00
fawney19 fce7e959e5 Add multi-database data layer
Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite.

Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers.

Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
2026-05-05 18:27:36 +08:00
mayrain 6b6d32b4a3 feat(usage): 新增输出速度统计与请求详情性能分析
- 把 upstream_is_stream 物化到 usage 与 billing facts,避免 Provider 聚合回连 public.usage
- 统一前端标准/流式 TPS 计算与显示,流式按首字后生成耗时计算
- 新增请求详情抽屉展示单请求输出速度与 Provider 聚合 TPS
2026-05-05 17:34:12 +08:00
mayrain f0197b685f fix(data): 同步模型调用次数至 global_models 读模型
- 模型列表改为读取 global_models.usage_count,避免每次扫描 usage 明细表
- 通过历史 backfill 与 usage upsert delta 维护该读模型
- 详情页保留实时 facts 兜底,统一排除 pending/streaming 状态
2026-05-05 17:34:02 +08:00
mayrain 53fa33b0c5 fix(dashboard): 修复仪表盘与明细统计数值不一致并重建 cost_savings 聚合
- 统一 dashboard 聚合与 raw 查询的 token 计算,拆分今日节省和周期节省
- cache savings 改用 input price 估算未命中成本,修复历史 cost_savings 偏高
- raw 查询 total_tokens 改用 effective_input + output + cache_creation + cache_read 公式
- 新增独立 backfill 重建历史 cost_savings 聚合表,保留已发布 backfill 不变
2026-05-05 17:33:16 +08:00
mayrain 2f915b33c7 perf(usage): 重构多天聚合查询为数据库端按天分组计算
- 多天范围的模型、供应商和 API 格式分析复用已有用户日聚合表
- 只对未完成窗口回退明细查询,再合并排序截断
- 避免长范围直接扫 usage_billing_facts 视图
2026-05-05 17:33:04 +08:00
mayrain fd8230121c fix(usage): 修复多天统计 NUMERIC→f64 解码失败导致记录清空
- 多天统计从日聚合表 SUM numeric 字段后返回 NUMERIC,代码按 f64 解码失败
- 显式添加 ::DOUBLE PRECISION 类型转换
- 管理员统计加载失败时不再清空已加载的记录列表
2026-05-05 17:32:50 +08:00
fawney19 825c1b496d Merge pull request #374 from Entropy-Xu/codex/provider-performance-stats
feat(stats): 添加 Provider 性能统计分析
2026-05-05 13:16:06 +08:00
fawney19 ccde3d4045 style(gateway): format secret masking changes 2026-05-05 13:04:56 +08:00
fawney19 f7071967c0 fix(gateway): mask catalog secrets on char boundaries 2026-05-05 12:48:19 +08:00
RWDai 657f9f0be1 fix: carry session affinity through Gemini files 2026-05-05 12:25:07 +08:00
RWDai 628329e493 fix: scope scheduler effects by session 2026-05-05 12:25:07 +08:00
RWDai 82e7a0080e fix: prefer client session adapters 2026-05-05 12:25:07 +08:00
Entropy.Xu ff1ed8b57d feat(stats): 添加 Provider 性能统计分析 2026-05-05 11:24:16 +08:00
RWDai 17b06bd4d6 refactor: abstract model fetch strategies 2026-05-05 11:23:54 +08:00
RWDai 5fcb49b08e feat: wire specialized planner session affinity 2026-05-05 11:23:54 +08:00
RWDai 044ef59d81 feat: wire OpenAI responses session affinity 2026-05-05 11:23:54 +08:00
RWDai 51b191ad2c feat: wire OpenAI chat session affinity 2026-05-05 11:23:54 +08:00
RWDai e890a5c2f1 feat: wire standard family session affinity 2026-05-05 11:23:54 +08:00
RWDai 591dcf5cf2 feat: carry session affinity through candidate materialization 2026-05-05 11:23:54 +08:00
RWDai 58d6b2add6 feat: scope planner affinity cache by session 2026-05-05 11:23:54 +08:00
RWDai bcd542f7ee feat: pass session affinity through planner inputs 2026-05-05 11:23:54 +08:00
RWDai 37da4e245a feat: scope tunnel affinity by client session 2026-05-05 11:23:54 +08:00
RWDai 9ef7952da5 fix: keep scheduler affinity out of runtime checks 2026-05-05 11:23:54 +08:00
RWDai eb8878695a feat: thread session affinity through scheduler selection 2026-05-05 11:23:54 +08:00
RWDai c596d82dec feat: pass session scope into scheduler cache 2026-05-05 11:23:54 +08:00
RWDai 96dd3fa4ca feat: add gateway session affinity adapters 2026-05-05 11:23:54 +08:00
RWDai 14eeff8f75 fix: keep affinity out of scheduler eligibility 2026-05-05 11:23:54 +08:00
RWDai 2cbbd9ca36 feat: add session-aware scheduler keys 2026-05-05 11:23:54 +08:00
fawney19 627eb4f335 Merge pull request #371 from Kayphoon/feature/embedding-model-support
feat: add embedding and rerank support
2026-05-04 12:56:11 +08:00
fawney19 8dd4135f7c Fix provider key insert placeholder order 2026-05-04 12:21:54 +08:00
Kayphoon 6bdd7792eb fix: repair embedding rerank CI checks 2026-05-04 11:33:00 +08:00
fawney19 099653f732 Cap Codex pool cooldowns and add key circuit breaker 2026-05-04 02:15:32 +08:00
fawney19 1d62722d47 Improve Codex model fetching 2026-05-04 01:16:56 +08:00
fawney19 fb642f7d62 feat: improve OAuth provider configuration 2026-05-04 00:18:05 +08:00
fawney19 cb5b8ee1ee Use Rust build metadata for system version 2026-05-03 22:06:16 +08:00
fawney19 b4a8c5dde2 Format execution runtime transport imports 2026-05-03 21:52:15 +08:00
fawney19 53a5e18b20 Merge pull request #370 from yao177/aether-rust-pioneer
fix(proxy): record manual proxy node traffic
2026-05-03 21:40:20 +08:00
fawney19 6f00cabe96 Lazy load requested model candidates 2026-05-03 20:56:31 +08:00
fawney19 a24e4a793d Refactor pool candidate scheduling 2026-05-03 20:14:29 +08:00
fawney19 8ebee9922c fix: return auth mismatch key setting in provider key list 2026-05-03 20:11:49 +08:00
Yao177 c3d97b8c16 fix(proxy): record manual proxy node traffic
- add manual proxy traffic mutations to the data layer
- record sync and stream outcomes for manual proxy requests
- prevent tunnel proxy nodes from receiving manual traffic updates
2026-05-03 18:38:04 +08:00
Kayphoon 5abe664d65 feat: add embedding and rerank support 2026-05-03 17:32:41 +08:00
jiuwovo-aiandroot 3e2eca4fd0 fix: 修复手动代理节点请求不计数及延迟/心跳不显示的问题 (#368)
* fix: 修复手动代理节点请求不计数及延迟/心跳不显示的问题

问题描述:
- 手动添加的代理节点请求数始终为0,不会递增
- 手动代理节点的延迟和最后心跳时间不显示

根因:
Rust 重写版中缺失了 Python 版的手动代理节点请求计数逻辑。
隧道节点通过心跳上报计数,但手动节点没有心跳机制,
需要在 usage recording 路径中递增计数并更新延迟信息。

修复方案:
1. 在 ExecutionPlan 中提取 proxy 信息,注入到 request_metadata
2. 在 request_metadata 白名单中添加 proxy 字段
3. 新增 INCREMENT_MANUAL_PROXY_NODE_REQUESTS_SQL,
   递增 total_requests/failed_requests,
   同时更新 avg_latency_ms 和 last_heartbeat_at
4. 在 ProxyNodeWriteRepository trait 新增 increment_manual_node_requests 方法
5. 在 write_event_record 中解析 request_metadata 中的 proxy 信息,
   对非隧道模式的手动节点调用递增方法
6. 为 GatewayDataState 实现 ManualProxyNodeCounter trait

影响范围:
- 仅影响手动代理节点的统计数据
- 隧道节点不受影响(继续通过心跳计数)
- 不影响请求转发逻辑

* style: cargo fmt

---------

Co-authored-by: root <[email protected]>
2026-05-03 14:50:36 +08:00
fawney19 c4ea042eb4 feat: add model directive management 2026-05-03 14:48:25 +08:00
Kayphoonandfawney19 fe27fb17fb feat: add provider-key concurrent limit (#352)
* feat: add provider-key concurrent limit

* Fix provider key concurrent limit checks

---------

Co-authored-by: fawney19 <[email protected]>
2026-05-03 01:55:23 +08:00
fawney19 11c5884d4f Remove API format rate multiplier field 2026-05-03 00:55:59 +08:00
fawney19 e3ea2d1451 feat: configure auth channel mismatch formats 2026-05-03 00:49:22 +08:00
fawney19 3a770306cc refactor: separate request auth channel from route kind
Extract request_auth_channel as a distinct routing dimension to distinguish
between api_key and bearer_like authentication methods. This improves routing
clarity by decoupling authentication mechanism from route classification.
2026-05-02 21:23:33 +08:00
fawney19 47ee8b9c13 refactor: fold ai surfaces into formats 2026-05-02 18:19:39 +08:00
bfd1ea72b3 fix: codex free accounts incorrectly marked as quota exhausted (#366)
* fix: codex free accounts incorrectly marked as quota exhausted when skip_exhausted_accounts enabled

- Cross-validate stored exhausted flag against window used_ratio in snapshot reader
- Guard has_credits:false check with window data presence in upstream_metadata fallback
- Add windows.is_empty() precondition to exhausted_by_credits in snapshot builder

Free codex accounts have has_credits:false (they use window-based quotas, not credits),
but the old logic treated this as credits depleted, skipping actual window usage checks.
This caused all free accounts to be incorrectly marked exhausted.

* test: cover codex free quota windows

---------

Co-authored-by: root <[email protected]>
Co-authored-by: fawney19 <[email protected]>
2026-05-02 14:11:29 +08:00
fawney19 c130d0e2c9 refactor ai serving modules and crates 2026-05-02 13:23:54 +08:00
fawney19 4fc7cecf30 Fix OAuth token import and table filters 2026-05-01 02:14:49 +08:00
fawney19 9570e5c2c1 Fix legacy Claude and Gemini auth migration 2026-04-30 18:01:58 +08:00
fawney19 33aa70c22b fix scheduler affinity candidate selection 2026-04-30 16:27:24 +08:00
fawney19 558abfcfa3 Route request origin through ai pipeline facade 2026-04-30 12:15:51 +08:00
fawney19andRWDai 7fcb9e6292 feat(usage): record request origin metadata (#362)
Record client IP and User-Agent in usage request_metadata for local execution requests without adding schema, API, or table fields.

References #357 and supersedes #358.

Co-authored-by: RWDai <[email protected]>
2026-04-30 11:26:56 +08:00
RWDaiandfawney19 2b5247b9a8 fix(data): repair request candidate epoch created_at (#343)
Co-authored-by: fawney19 <[email protected]>
2026-04-30 09:23:21 +08:00
fawney19 fa47e8a3c0 feat: add select all to access limit dropdowns 2026-04-30 00:45:53 +08:00
Entropy.Xu 4d59d518d1 fix: correct API key expiry and dashboard savings (#361) 2026-04-30 00:35:32 +08:00
fawney19 9e2faa7e5b Tighten local auth allow-list matching 2026-04-30 00:26:33 +08:00
fawney19 37392d8774 fix(ai): allow null OpenAI Responses error fields 2026-04-29 21:12:14 +08:00
fawney19 a16550249b fix: allow empty provider secrets to attempt requests 2026-04-29 18:55:52 +08:00
fawney19 d6de917878 Fix key API format auth help 2026-04-29 17:35:16 +08:00
fawney19 e751289dfb Support per-format provider key auth 2026-04-29 15:46:50 +08:00
fawney19 07a319259b Normalize canonical API formats 2026-04-29 10:20:41 +08:00
fawney19 02ad67fe33 fix(test): update openai format identifier from cli to responses 2026-04-28 21:04:38 +08:00
github-actions[bot] 655e369f21 chore(proxy): update download links for proxy-v0.3.3 2026-04-28 12:04:24 +00:00
fawney19 a9b809a32c chore(proxy): bump version to 0.3.3 2026-04-28 19:57:29 +08:00
fawney19 230e7d6259 Normalize management token prefixes 2026-04-28 19:46:38 +08:00
fawney19 3d20c05ef7 Fix OAuth refresh through tunnel proxy 2026-04-28 19:45:34 +08:00
fawney19 9194f78e56 OAuth refresh 隧道代理统一启用 follow-redirects 2026-04-28 17:47:28 +08:00
fawney19 70f747d406 Add shared OAuth flows 2026-04-28 15:46:21 +08:00
AAEE86 712b484bc8 fix(scheduler): 恢复正则模型映射作为上游模型 (#355)
保留 provider_model_mappings 的优先级选择逻辑,但恢复
GlobalModel model_mappings 正则命中后的行为:使用命中的
allowed_model 作为实际上游 mapped_model。

修复了 #354 请求绕过基于正则表达式的模型映射的问题
2026-04-28 10:36:50 +08:00
fawney19 0c9b5ddd77 Fix usage terminal state regression 2026-04-28 10:31:16 +08:00
fawney19 3f1abb6906 Format OAuth refresh consistency changes 2026-04-28 09:54:35 +08:00
fawney19 29fc0be121 Tighten OAuth refresh consistency 2026-04-28 09:36:42 +08:00
Kayphoon 5311eb0da1 Fix provider model mapping selection (#354) 2026-04-28 09:23:39 +08:00
fawney19 321f21ad49 Fix provider OAuth refresh token reuse 2026-04-28 02:00:35 +08:00
fawney19 639f26ed5d fix: force http1 for tunnel oauth refresh 2026-04-28 01:37:40 +08:00
fawney19 032dcf40e7 fix: filter candidate rows to resolved global model name only 2026-04-28 01:12:27 +08:00
fawney19 9a67497d8c fix: align oauth tunnel refresh transport 2026-04-28 01:10:34 +08:00
fawney19 6426b5d80e chore: add oauth refresh diagnostics 2026-04-28 00:18:51 +08:00
fawney19 83e1f99ccf fix: invalidate oauth transport cache after refresh 2026-04-27 23:35:05 +08:00
fawney19 05b8b8a442 fix: bypass oauth cache for forced refresh 2026-04-27 23:04:31 +08:00
fawney19 ed17147281 fix: align provider oauth refresh redirects 2026-04-27 22:08:02 +08:00
fawney19 a7a3c9f023 Fix OAuth import proxy handling 2026-04-27 20:05:43 +08:00
fawney19 4c16b11cb4 Align runtime miss tests with skipped candidates 2026-04-27 18:09:56 +08:00
fawney19 17f09fc8c1 Expose ranking metadata in request trace 2026-04-27 17:24:32 +08:00
fawney19 3e0293ac28 Rename local candidate resolution entrypoints 2026-04-27 17:24:32 +08:00
fawney19 07fba70a90 Guard pool scheduler ranking boundary 2026-04-27 17:24:32 +08:00
fawney19 da0e968975 Update transport ranking architecture guard 2026-04-27 17:24:32 +08:00
fawney19 98e4e91a98 Rename transport ordering facts 2026-04-27 17:24:32 +08:00
fawney19 218e73e324 Use core affinity helpers directly 2026-04-27 17:24:32 +08:00
fawney19 51c3beb614 Narrow core ranking public surface 2026-04-27 17:24:32 +08:00
fawney19 9da47ceb22 Remove duplicate capability candidate sorting 2026-04-27 17:24:32 +08:00
fawney19 e9f03d8d29 Remove ranked minimal selection compatibility helper 2026-04-27 17:24:32 +08:00
fawney19 e5e09b49f4 Remove ranked candidate data read wrappers 2026-04-27 17:24:32 +08:00
fawney19 dda6f34a07 Rename ranked minimal candidate reads 2026-04-27 17:24:32 +08:00
fawney19 44930532dc Align ranked candidate selection test names 2026-04-27 17:24:32 +08:00
fawney19 f10f5f071d Clarify ranked minimal candidate selection 2026-04-27 17:24:32 +08:00
fawney19 517e84e5ae Move local ranking test helper into tests 2026-04-27 17:24:31 +08:00
fawney19 2101a4ecc7 Drop legacy candidate ordering helpers 2026-04-27 17:24:31 +08:00
fawney19 8d2cbf32df Remove legacy tunnel-only candidate ordering 2026-04-27 17:24:31 +08:00
fawney19 a23758808d Isolate core candidate selection helpers 2026-04-27 17:24:31 +08:00
fawney19 91db4eefd0 Persist ranking metadata through request traces 2026-04-27 17:24:31 +08:00
fawney19 f52220a8ec Clean up planner candidate modules 2026-04-27 17:24:31 +08:00
fawney19 1d23bf4ecb Isolate standalone key wallet handling 2026-04-27 17:24:31 +08:00
fawney19 3b542434a2 Unify candidate ranking pipeline 2026-04-27 17:24:31 +08:00
Kayphoon 9b866a6d17 fix(auth): 修复独立密钥继承用户访问限制 (#347) 2026-04-27 12:32:21 +08:00
Entropy.Xu d8f66b14a8 Handle Codex image stream rate limits (#346) 2026-04-27 12:23:48 +08:00
Entropy.Xu 6e1eaf8aec fix(kiro): 修复 Kiro WebSearch MCP 调用 (#344)
* fix(kiro): 接入 Kiro MCP web_search 工具调用

* fix(kiro): 对齐 Kiro IDE MCP 鉴权与 profileArn 头部

* fix(responses): 保留 Responses 嵌套与自定义工具参数

* test(ci): 通过 Rust Action 三项检查
2026-04-27 12:20:26 +08:00
fawney19 488bd08f04 Stabilize Kiro Claude CLI sync tests 2026-04-27 00:45:48 +08:00
fawney19 3991d47166 Fix format checks and local error messages 2026-04-27 00:27:03 +08:00
fawney19 0c73f245ab shrink formats conversion to compatibility facades 2026-04-26 23:59:53 +08:00
fawney19 989b27426b refactor formats adapters and matrix ownership 2026-04-26 23:59:53 +08:00
fawney19 c10cd8240e refactor ai formats registry adapters 2026-04-26 23:59:53 +08:00
fawney19 5cd2244bc2 fix frontend openai responses alias display 2026-04-26 23:59:53 +08:00
fawney19 0ec4b4c8a4 guard legacy openai alias compatibility 2026-04-26 23:59:53 +08:00
fawney19 4ec591fbf2 centralize openai responses alias handling 2026-04-26 23:59:53 +08:00
fawney19 ea3dc3257e clean up legacy openai cli adapter names 2026-04-26 23:59:53 +08:00
fawney19 5b914aa78c migrate ai format conversion to responses adapters 2026-04-26 23:59:53 +08:00
Entropy.Xu e36fb8c07a Honor global format conversion override (#341)
Apply the global enable_format_conversion setting when building provider transport snapshots so enabled global conversion overrides provider-level settings at runtime.

Keep the cached provider snapshot unchanged so disabling the global switch restores each provider's original conversion configuration.
2026-04-26 23:54:52 +08:00
Kayphoon 1af9d00abd fix(gateway): 修复仅配置 provider retry 时 429 不重试 (#338)
- attempt.rs: 从 failover_rules/endpoint/provider 三级读取 max_retries 生成 attempt slots,支持配置 provider retry 后 429 自动重试,限制最大 99 并兼容 legacy 默认值 2
2026-04-26 23:50:14 +08:00
Entropy.Xu e8763b3bbd fix(kiro): 放行可刷新 OAuth 调度候选 (#340) 2026-04-26 23:49:55 +08:00
AAEE86 f3a9fd4c82 feat(gateway): 补齐号池主动探测后台任务 (#342)
- 解析 pool_advanced 主动探测开关与间隔配置
- 对齐 Python 版本的探测间隔默认值和范围限制
- 新增号池配额主动探测 worker
- 支持 codex、kiro、antigravity provider
- 使用 Redis 时间戳和 provider 级锁避免重复探测
- 接入 gateway 后台任务并补充测试
2026-04-26 23:48:31 +08:00
fawney19 baa0ddd787 Improve runtime miss usage diagnostics 2026-04-26 01:44:54 +08:00
fawney19 1d3ea3232d Add unified candidate failure diagnostics 2026-04-26 01:38:37 +08:00
Entropy.Xu d784c540b6 fix(kiro): 隐藏 OAuth 刷新重试流程 (#339) 2026-04-25 21:29:45 +08:00
fawney19 429fdb47e6 fix(usage): 简化本地执行错误提示 2026-04-25 20:46:22 +08:00
fawney19 912a92cd1a refactor(rules): 规则引擎容错优化,无效规则条目跳过而非中止整个规则集
- header/body rules 的 _are_locally_supported 简化为仅检查是否为数组
- apply 逻辑中遇到格式错误/不支持的规则条目改为 continue 跳过,而非 return false
- 允许非字符串 header value,自动序列化为 JSON 字符串
- 宽松处理无效 regex flag,不再拒绝整条规则

fix(gateway): Claude CLI 路由仅检查 bearer 头,不排斥同时携带 x-api-key 的请求

feat(observability): 监控链路候选展示解密 auth_config 的账号标签和 OAuth 计划类型
2026-04-25 19:46:52 +08:00
fawney19 00744c0ce5 feat(observability): 引入错误链路 error_flow 元数据并区分上游/客户端错误
- 网关在本地 failover 时构建 error_flow 元数据(分类/决策/传播策略),写入 report_context
- scheduler-core 解析并透传 error_flow 至候选 extra_data
- admin usage 详情拆分 request/upstream/client/failure_summary 错误域,敏感上游错误标记为 suppressed
- 前端 RequestDetailDrawer 拆出"返回客户端"与"上游响应"双错误卡片
- HorizontalRequestTimeline 节点详情展示真实请求错误及 error_flow 标签
2026-04-25 17:01:07 +08:00
Entropy.Xu bc97e383d3 fix(gateway): 对齐号池调度预设行为 (#332)
- 保持 sticky session 优先于分配模式排序,并支持 sticky_session_ttl_seconds=0 禁用粘滞绑定
- 移除旧版 free_team_first 预设入口,统一使用 free_first / team_first 新模式
- 对齐 plus_first 对 Plus/Pro 账号的优先级处理
- 调整负载均衡排序种子,避免同一分钟内固定命中同一 Key
- multi-score 预设下记录延迟样本,并补充号池调度/配置/运行时测试
- 收敛 request candidate extra_data 构造入参,修复 clippy too_many_arguments
2026-04-25 16:41:51 +08:00
RWDai b8205b5a09 Fix/usage json entity escaping (#333)
* fix(usage): 修复 JSON 视图实体转义显示

* fix(usage): 解码 OpenAI 工具参数实体
2026-04-25 16:41:25 +08:00
Entropy.Xu bb7fe9fe37 fix(kiro): 修复 Kiro OAuth 过期懒刷新 (#334)
* fix(kiro): 修复 OAuth 过期懒刷新

* fix(kiro): 修复 OAuth 过期懒刷新

* fix(scheduler): 修复 candidate extra data clippy 告警
2026-04-25 16:40:45 +08:00
fawney19 cef7dcac71 refactor(usage): 抽取 JsonContentPanel 通用 JSON 展示组件
请求详情抽屉与时间线统一复用工具栏(展开/收缩、复制)和 JSON 视图,时间线的额外信息也由 pre 改为可交互面板
2026-04-25 11:44:01 +08:00
fawney19 61e12e2c17 feat(scheduler): candidate report 携带 header_rules 与 body_rules
各 planner 在构造 report context 时附带端点的 header/body 改写规则,scheduler 解析后写入 candidate extra_data,便于追踪请求实际应用的透传规则
2026-04-25 11:43:53 +08:00
fawney19 65e915fd1d refactor(api-keys): 网关 API Key 改为 32 位字母数字随机串
由 UUID 拼接(48 位 hex)改为 base62 字母数字,缩短长度并提升可读性
2026-04-25 10:04:04 +08:00
fawney19 1761921670 fix(usage): 移动端缓存 token 拆分为读/写两列展示
cache_read 与 cache_creation 原本被合并为单值,现分别显示并补充测试
2026-04-25 09:53:32 +08:00
fawney19 e5bbc797e0 chore(gateway): 调小 Postgres 连接池默认值
- max_connections 100 → 20
- idle_timeout 60s → 30s
- .env.example 补充连接池相关变量注释
2026-04-25 09:53:21 +08:00
fawney19 f30b1f3f6b fix(usage): 流式终端 usage 以更完整值为准,Codex CLI 显式选择 response 解析器
- 新增 StandardizedUsage::signal_score/is_more_complete_than/choose_more_complete,流式合并与终端落库均按信号完整度择优
- OpenAI Chat/CLI 解析器支持仅 usage 的终结 chunk 与 response.completed usage
- Codex provider 注入 provider_stream_event_api_format=openai:cli,解析器选择改由 report_context 显式决定
- usage_mapper 扩展嵌套 response/message/item 兼容 Claude message_start/message_delta 及 Gemini stream chunks
- usage SQL upsert 在终态(completed/failed/cancelled)时按 GREATEST 写入 token/费用镜像列
2026-04-25 00:57:01 +08:00
fawney19 2c6209277f fix(usage): Claude 流式合并 message_start/delta usage,前端移动端缓存 token 合并展示
- Claude provider state 在 message_start 记录基础 usage,message_delta 合并 output_tokens
- 前端 UsageRecordsTable 移动端缓存列改为 cache_creation + cache_read 合计展示
2026-04-24 22:28:04 +08:00
fawney19 5b3593038d fix(usage): OpenAI 缺失 input_tokens 时从 total 回推
- 同步与流式两条路径均在 input_tokens 为 0 且 total > output 时用 total - output 兜底
- 补充对应单元测试
2026-04-24 22:08:23 +08:00
Entropy.Xu 67b092253d fix(gateway): 兼容 OpenAI 图像 multipart boundary 大小写 (#331) 2026-04-24 21:41:20 +08:00
fawney19 db1059e73b fix(usage): 修复 Gemini total 重复累加 cache_read 并补充测试
- write.rs: Gemini usage 提取 total 时不再叠加 cachedContentTokenCount
- 补充 Claude 大 cache_read 场景下 input_tokens 不被扣减的测试
- 补充前端 getEffectiveInputTokens 对 Claude 格式不减 cache_read 的测试
2026-04-24 21:20:45 +08:00
fawney19 e265475c17 fix(usage): 修正 input_tokens 统计与显示
- SQL 查询不再用 settlement snapshot 的 billing_input_tokens 覆盖原始 input_tokens
- usage_mapper 针对 OpenAI 格式在 input_tokens 缺失时从 total_tokens - output_tokens 推导
- 前端轮询合并优先采用最新 record 的 input_tokens / effective_input_tokens
2026-04-24 20:59:12 +08:00
Entropy.Xu 343345529d fix(kiro): 修复 Claude CLI 跨格式 Responses 流式转换 (#329) 2026-04-24 20:23:02 +08:00
fawney19 657fcd595c refactor(rust): 拆分 usage sql 仓储为模块并外置 SQL 文件
- 将 usage/sql.rs 拆分为 sql/mod.rs 与 sql/tests.rs
- 将内联 SQL 抽离到 sql/queries/ 目录下的独立 .sql 文件
- 前端用量记录表格移除缓存创建 token 展示,保留 cache read 列
2026-04-24 20:22:24 +08:00
fawney19 d0d71aa51a fix(gateway): OpenAI 图像 finalize 统一返回 b64_json 并对齐 codex CLI 候选流式上游
- finalize 移除 response_format=url 的 data URL 分支,统一输出 b64_json
- image planner decision 依据请求体中的 stream 字段决定 upstream_is_stream
- openai chat sync plan 对 codex+openai:cli 候选强制上游流式
- 扩充 conversion registry 与 standard matrix 的全量 surface 对端测试
2026-04-24 19:01:11 +08:00
AAEE86 4618184516 fix: 修复账号批量操作快捷多选不生效和今日 Token 不准确 (#328)
* fix(pool): 修复账号批量操作快捷多选不生效

- 解析号池账号列表接口的 quick_selectors 查询参数
- 列表查询复用现有快捷筛选匹配逻辑
- 支持冷却列表与普通列表的快捷筛选后分页
- 更新架构测试中的 pool admin helper 边界断言

* fix(dashboard): 今日 Token 改为按分项汇总口径展示

- /api/dashboard/stats 的 today.tokens 改为 输入+输出+写缓存+读缓存
- “今日 Token”卡片 value 与 subValue 口径保持一致
- 同步更新 dashboard 相关测试断言
2026-04-24 18:36:38 +08:00
fawney19 3cc54deb9c fix(gateway): SSE 流跳过成功探测与 prefetch,统计聚合 SQL 显式 CAST BIGINT
- stream: event-stream 响应跳过 direct finalize prefetch 与成功失败转移探测,避免消费流首帧
- stream_pump: 仅在已知 content-length 时缓冲非 SSE 响应
- maintenance: usage_billing_facts 聚合列显式 CAST 为 BIGINT,避免类型不匹配
2026-04-24 18:14:59 +08:00
fawney19 f695238e8a feat(billing): 引入 billing v3 settlement snapshot 及 usage_billing_facts 视图
- 新增迁移 20260424000000:为 usage_settlement_snapshots 添加 settlement_snapshot、
  billing_dimensions、billing_input/output/cache tokens、billing_total_cost_usd 等列,
  并创建 usage_billing_facts 视图(从 settlement snapshot 覆盖原始 usage token/cost 字段)
- event_enrichment:构建结构化 settlement_snapshot(含 pricing_snapshot、billing_plan_snapshot、
  resolved_dimensions、cost_breakdown 等),写入 request_metadata
- pricing:新增 pricing_source() 方法区分 provider_override / global_default / unpriced
- sql.rs:settlement snapshot 写入新列;用量汇总查询改用 usage_billing_facts 视图
- maintenance/runtime:所有统计查询的 FROM usage 替换为 FROM usage_billing_facts
- admin observability:在 settlement 响应中暴露 settlement_snapshot / billing_dimensions,
  并从 metadata 中剥离对应字段
- request_metadata:允许 settlement_snapshot / billing_dimensions 字段传播
- stream execution:在首个数据帧到达时记录 TTFB,补全流式请求的 streaming 事件
2026-04-24 17:52:33 +08:00
Entropy.Xu fb46fcd80f feat(rust): 对齐 SMTP 测试与注册邮件 TLS 链路 (#325)
* feat(rust): 对齐 SMTP 连接测试链路

* fix(rust): 修复注册邮件 TLS provider 初始化
2026-04-24 14:59:00 +08:00
fawney19 e0c928fbe8 feat(auth): /me 接口返回 has_password 字段,前端 Profile 类型同步调整 2026-04-24 14:52:49 +08:00
fawney19 780f09c1a2 feat(billing): 引入 model_id 精确计费查找路径,传播模型 ID 至用量事件与 report context
- UsageEventData 新增 model_id / global_model_id 字段,write.rs seed 结构体同步补充
- report_context 新增 model_id / global_model_id / global_model_name,各 payload 构建时从 candidate 传入
- event_enrichment 优先按 model_id 精确查找计费上下文,回退为按名称多轮查找(保留 NoRule 结果降级逻辑)
- BillingReadRepository 新增 find_model_context_by_model_id,memory/sql 分别实现;SQL 查询重构支持按 provider_model_name 和 mappings 匹配并按优先级排序
- pricing.rs 修复:model_tiered_pricing 为空 tiers 时回退到 default_tiered_pricing
- request_metadata 允许字段列表补充 model_id / global_model_id / global_model_name
- admin usage 路由信息输出及脱敏字段列表同步新增三个 model 相关字段
2026-04-24 14:40:28 +08:00
fawney19 f3c9835759 feat(pool): 引入 pro_first 调度预设、Pool 候选持久化跳过与诊断信息优化
- 新增 pro_first 调度预设(Pro 优先),更新 plus_first 仅针对 Plus 计划,移除 free_team_first
- Pool 内部候选(pool_key_index 不为空)跳过 DB 持久化(available/skipped/unused 均适用)
- LRU 排序新增 catalog_lru_score 回退:runtime 无记录时使用 last_used_at_unix_secs
- 执行路径 miss 诊断消息细化为中文,按 reason 分类输出可读说明
- build_local_request_candidate_status_record 补充 extra_data 和 created_at_unix_ms 字段
- OpenAI CLI 计划构建流程补充候选评估进度跟踪与 terminal reason 设置
- 前端 PoolSchedulingDialog 增加 pro_first 预设展示,修复 LRU 默认预设检测逻辑
2026-04-24 13:29:05 +08:00
fawney19 f29649e3a8 feat(stream): 在流式执行中引入 StreamingStandardTerminalObserver 用量采集
- 在 execute_stream_from_frame_stream 中集成 StreamingStandardTerminalObserver,对流式响应逐行观察并在结束时合并终态摘要
- 新增 observe_stream_usage_bytes / finalize_stream_usage_observer / merge_stream_terminal_summary 辅助函数
- 更新 codex-cli 流式集成测试:补充 response.completed 含完整 usage 字段的 mock 数据,并断言用量写入 usage_repository
2026-04-24 11:34:41 +08:00
Dalamudandfawney19 35400b0c2d fix(aether-ai-pipeline): fix same-format Responses finalize output reconstruction (#323)
- rebuild final Responses output from streamed SSE events when terminal output is empty
- preserve authoritative completed output, multipart content ordering, reasoning/non-text parts, and final annotations
- update gateway finalize tests to match reconstructed output behavior

Co-authored-by: fawney19 <[email protected]>
2026-04-24 10:32:39 +08:00
fawney19 5d710d9d10 feat(rust): 支持 image 同步转流式 SSE、free_team_first 调度预设及账户错误自动重试
- openai:image 同步响应桥接为流式 SSE(image_generation.completed / image_edit.completed 事件)
- image 请求解析与前置校验移除模型白名单,支持任意自定义模型名
- 调度器新增 free_team_first 预设(mode: both / free_only / team_only)
- pool config 解析重构:新增 POOL_ALLOWED_SCHEDULING_PRESETS 白名单,规范化 mode 字段
- 错误分类器新增账户/账单错误模式集,升级为 RetryUpstreamFailure 而非 StopSemanticClientError
- 修复 stream execution 中上游 headers 与输出 headers 混用导致 content-type 判断错误的问题
- codex image 工具始终写入 action 字段(generate/edit),仅 generate 操作填充默认 size/quality
- 前端:pool 节点组只展示实际执行过的候选节点,全部 skipped 时折叠为最后一个节点
- Redis 测试就绪检测从 TCP 连接改为 PING/PONG 协议验证
2026-04-24 10:07:24 +08:00
AAEE86 a9d10163af fix(oauth): 修复 OAuth 刷新后 Token 有效期不更新问题 (#324)
- 持久化刷新后的 `expires_at` 到 Provider Key SQL 更新链路
- 手动刷新接口优先返回本次刷新得到的过期时间
- 按当前 Key 字段重建 OAuth 状态快照,避免旧快照覆盖
- 前端刷新后防止旧列表数据回退覆盖新有效期
2026-04-24 09:35:25 +08:00
Entropy.Xu 31e871fe1b fix(kiro): 修复流响应识别与号池配额刷新 Token 续期问题 (#322)
* fix(kiro): 兼容 application/json 头下的 eventstream 流响应

- stream_pump: 对带 kiro:generateAssistantResponse envelope 的上游响应按流处理,不再仅依赖 text/event-stream 头判断\n- tests: 补充 application/json 头下仍识别为 Kiro stream 的回归测试

* fix(kiro): 修复号池配额刷新误判封禁
2026-04-24 09:34:34 +08:00
fawney19 5148370253 fix(usage): 修复含失败信号的 pending 请求被误判为活跃的问题
- pending/streaming 状态含 status_code >= 400 或 error_message 时视为 failed
- 候选状态派生改为 failed 优先于 stale pending/streaming
- 活跃请求轮询时保留失败信号字段的更新
- 图片流支持 partial_image 事件转发及从 response.completed 读取最终图片
2026-04-24 03:07:11 +08:00
fawney19 581bc03d4e feat(ai-pipeline): 支持 reasoning signature 及媒体内容块的跨格式流式转换
- 在 CanonicalStreamEvent 中新增 ReasoningSignature 和 ContentPart 变体,CanonicalContentPart 枚举覆盖图片/文件/音频
- Gemini 流解析器提取 thoughtSignature,并对 inlineData/fileData 等非文本 part 生成 ContentPart 事件
- Claude 流聚合支持 thinking_delta / signature_delta,输出 thinking block 携带 signature 字段
- Gemini 同步响应聚合重写,支持媒体 part 和 reasoning signature 的完整还原
- 跨格式矩阵(gemini↔claude↔openai)补全图片块双向转换及 reasoning signature 传递
- 请求转换层(to/from openai_chat)补全 Claude/Gemini 的 thinking、图片、工具调用字段映射
- 新增/扩展测试:inline image 双向重写、thinking signature 聚合、跨格式 sync product 媒体字段
2026-04-24 02:18:34 +08:00
Entropy.Xu 0f94f92c37 fix(provider): 将rust分支的gemini cli端点行为对齐到python分支 (#321)
* fix(provider): 对齐 Vertex/Gemini 上游发包与 Python master

- provider-transport: 为 custom+aiplatform 推断 Vertex API key 上下文并统一 URL 构建顺序,复用共享 request_url 构建最终上游地址
- ai-pipeline/gateway: Vertex Gemini 路径改为仅使用 URL query key,不再向上游附带 x-goog-api-key header;同步对齐 standard/admin/test-connection/runtime miss 摘要中的最终 URL
- gemini conversion: 按 Python master 输出 Gemini 请求体,补齐 system_instruction / generation_config / tool_config / function_declarations 形态,并移植 Gemini schema 清洗逻辑
- scheduler/executor: 将最终 upstream_url、mapped_model、key_name 写入候选 extra_data,运行时 miss 诊断优先展示真实展开后的上游 URL 便于服务器排障

* fix(provider): 修复 Vertex provider 测试与本地调度链路

* fix(provider): 对齐 Vertex 本地执行与 Rust CI
2026-04-23 23:01:06 +08:00
AAEE86 ccec46eddd fix(admin-api-keys): 独立余额 Key 统计与时间字段改为直读 api_keys 表 (#319)
- 列表/详情/更新响应中的 `total_requests`、`total_tokens`、`created_at`、`last_used_at` 统一直接读取 `api_keys` 导出记录字段
- 保持“已消费”继续走钱包字段(不改消费口径)
- 在 auth 导出记录中补充 `last_used_at_unix_secs`、`created_at_unix_secs`、`updated_at_unix_secs`
- 扩展 auth SQL 查询与 RETURNING 字段映射,确保时间字段完整回传
- 移除 admin api-keys 路由中对 usage summary token 聚合的依赖
- 更新 admin api-keys 控制层测试,校验 token/时间字段来源与格式
2026-04-23 22:32:00 +08:00
Entropy.Xu ad58f4e852 fix(kiro): 修复 Kiro thinking 流重写按字节截断导致的 UTF-8 边界 panic (#320)
- events.rs: 保留 <thinking>/</thinking> 尾部窗口时改为按 UTF-8 字符边界拆分,避免中文流式 chunk 在 String 切片时 panic
- tests.rs: 抽取 Kiro report_context 构造并补充普通文本/思维块内多字节内容回归测试
2026-04-23 22:21:27 +08:00
fawney19 bb312ff0cf chore(tsconfig): 移除冗余的 baseUrl 配置 2026-04-23 22:05:02 +08:00
fawney19 342d4a268c feat(stream-bridge): 支持上游 sync 响应转 SSE 流式输出,记录 client/upstream 流模式差异
- 新增 sync_to_stream 桥接模块,将非 SSE 上游响应转换为 SSE 格式回传给流式客户端
- stream_pump 检测非 SSE 响应头后缓冲整包并通过桥接逻辑重写为 SSE 帧
- proxy handler 同步支持 sync→stream 聚合与转换(覆盖 openai/claude/gemini 四种格式)
- sync_products 补全 openai:cli 的完整流式事件聚合(text delta、reasoning、tool call 等)
- usage runtime 写入 client_requested_stream / upstream_is_stream 到 request_metadata
- SQL 查询层将两个布尔字段从 request_metadata jsonb 中提取并回传给前端
- 前端 status.ts 新增 resolveUsageStreamLabelSegments,优先读取 client_requested_stream
- RequestDetailDrawer 在流式转换场景下显示"客户端→上游"两段 Badge
2026-04-23 21:53:50 +08:00
fawney19 40282c3447 chore(migrations): 合并 ephemeral cache 迁移到 20260423000000,移除冗余迁移文件并同步 baseline cutoff 2026-04-23 14:53:54 +08:00
fawney19andEntropy.Xu fa328e18a1 feat: provider api_formats 可空继承、OpenAI 图片 edit/variation 与用量配额多项补强
- 鉴权: provider_api_keys.api_formats 改为可空,OAuth 托管 key 自动继承 provider endpoints 激活格式,相关 handler/测试同步更新
- 图片 planner: OpenAI 图片路由新增 edit/variation 操作并完善参数校验、响应合并与流式处理
- 用量: user me usage 返回区分 client_requested_stream/upstream_is_stream,前端 usage 列表筛选与展示增强
- 统计: stats_daily_model 新增 cache_creation_ephemeral_5m/1h tokens 字段与回填链路
- 配额/observability: quota repository 新增内存与 SQL 扩展,admin observability usage 字段扩充
- 其它: OAuth 导入/轮询收敛、provider 汇总与 pool admin 读写链路小修、新增 system_config 缓存与 provider template handler

Closes #318

Co-authored-by: Entropy.Xu <[email protected]>
2026-04-23 14:42:51 +08:00
Entropy.Xu f55f22d2e8 feat(codex-image): 封装 GPT Image 2 图片接口并收紧错误处理
- 新增 openai:image 路由、planner 与 finalize,内部通过 Codex responses image_generation tool 执行生图

- 补充 Codex OAuth/header 兼容、图片 success report 本地处理与相关前后端/集成测试

- 禁止 chat/completions 使用 gpt-image-2,图片接口限制 n=1,并移除 Provider 模型页的图片能力开关
2026-04-22 22:46:28 +08:00
fawney19 4374f53315 fix(adaptive): 修复 429 计数归零写入及 reset 语义
- sql.rs: UPDATE 时对 concurrent_429_count / rpm_429_count 加 COALESCE(, 0),避免 None 覆写为 NULL
- effects.rs: rpm_429_count 始终写入 Some(projection.rpm_429_count),不再过滤零值;新增 unknown-429 场景下零值持久化的单元测试
- adaptive.rs: reset 接口将两个计数字段置为 Some(0) 而非 None,与数据库默认语义对齐;同步修正集成测试断言
- docker-compose.build.yml: 补充 AETHER_GATEWAY_AUTO_PREPARE_DATABASE 默认开启
- README.md: 同步说明 build compose 也已默认开启自动迁移
2026-04-22 22:16:51 +08:00
fawney19 c8d7dbd8d6 refactor(gateway): 将 CF 头剥离中间件下移至各 router 构建函数并重构为前缀匹配 2026-04-22 21:10:16 +08:00
fawney19 cf6228f525 feat(stats,rules): api_key 用量统计(total_tokens 字段 + 回填)与 body rules 新增 append/insert/regex_replace/name_style 操作
- 新增迁移 20260422120000_add_api_key_usage_stats.sql,为 api_keys 表添加 total_tokens 列
- 新增回填 20260422120000_backfill_api_key_usage_stats.sql,按历史 usage 重建 api_key 维度汇总
- 在 UsageWriteRepository trait 中添加 rebuild_api_key_usage_stats,补齐 SQL/内存实现及上层调用链
- 内存实现中新增 apply_usage_stats_delta,支持增量更新 api_key 统计快照
- dev.sh:改进临时日志目录管理,并在网关异常退出时输出错误提示
- frontend EndpointFormDialog:将 append 操作从 insert 分支拆分,提供独立 path/value 输入 UI
- rules.rs:扩展 body rules 支持,新增 append/insert/regex_replace/name_style 操作及 WildcardSlice 路径段
2026-04-22 20:16:57 +08:00
fawney19 62153d7d36 feat(gateway): 新增 --auto-prepare-database 启动选项并在 docker-compose 默认开启
- aether-gateway 启动时可自动执行挂起的 migration 与 backfill
- docker-compose.yml 默认开启 AETHER_GATEWAY_AUTO_PREPARE_DATABASE
- 同步更新 .env.example、README 与 deploy.sh 的部署说明
2026-04-22 18:20:24 +08:00
fawney19 a5e6bd3b62 feat(stats): 新增聚合读路径与回填机制并重构 dashboard/usage 读取链路
- 新增 stats_user_summary 及 user_daily_provider/api_format/cost_savings 等聚合表
- 扩展 stats_daily/hourly 有效 token 与响应时间等字段,maintenance runtime 同步写入
- 新增 backfill 模块与 --apply-backfills 命令补齐历史聚合数据
- 重写 dashboard_filters、usage_heatmap、user_rollups 查询改走聚合表
- 同步更新 baseline_v2.sql 与 migration 集,README/dev.sh 补充回填用法
2026-04-22 17:29:39 +08:00
fawney19 063ef02306 fix(oauth): 系统导入时清理失效标记并强制刷新 refresh_token,前端徽章优先使用 account_id
- 后端 admin 系统导入 OAuth 凭据时重置 expires_at 与失效标记,并在存在 refresh_token 时触发一次本地刷新
- 前端 OAuth 徽章改为优先展示 account_id,采用去前缀后 5 字符的紧凑格式
2026-04-21 18:06:39 +08:00
fawney19 25a2b417be refactor: 优化调度候选排序与用量写入链路并改进 Fernet 缓存与前端批量列表 2026-04-21 16:19:07 +08:00
fawney19 c5c56ff92f feat(oauth): 允许替换已失效的活跃 OAuth 账号并同步 status_snapshot
- 活跃但 token 已过期或刷新失败的重复账号视为可替换
- 清除失效标记、刷新配额时同步更新 status_snapshot.oauth
- oauth_invalid 清除接口同时识别 invalid_at 与 invalid_reason 两种标记
- 批量导入任务状态区分 created_count / replaced_count,前端据此展示新增/替换统计
- 补充重复替换场景的集成测试,用量测试等待超时从 10s 提升到 30s 以适应并行压力
2026-04-20 22:59:02 +08:00
RWDai cf7d129595 fix(proxy-nodes): allow management tokens with json null allowed_ips (#317)
* fix(proxy-nodes): allow management tokens with json null allowed_ips

* style(proxy-nodes): format regression test
2026-04-20 21:50:48 +08:00
fawney19 226a6e58d5 refactor(gateway): 重构格式转换候选资格检查并优化测试基础设施
- 将 format_conversion_disabled 的过滤提前到候选遴选阶段,替代原来的 skip_reason 标记机制
- 为测试添加 execution_runtime_sync_override 直接注入支持,避免启动额外 HTTP 服务器
- 将 submit_terminal_event 改为 async record_terminal_event 确保失败用量事件可靠入库
- 新增 test_support 模块封装可重试的 loopback 端口绑定逻辑
- 前端:poolTrace 将 skipped 从隐藏状态移除,新增 buildPoolParticipatedCandidates 统一新旧链路逻辑,并将 skipped 状态色改为 foreground
2026-04-20 16:59:18 +08:00
fawney19 87afe4898e fix(frontend): 为配额自动刷新加入 5 分钟冷却并优化 OAuth org 徽章显示
- 新增 quotaAutoRefreshCooldown 工具,按 provider 维度限制后台自动刷新频率,手动触发(刷新 token、变更 key)通过 ignoreCooldown 绕过
- OAuth org 徽章去除 org- 前缀后再缩略,避免 org:org-xx 的重复前缀,并缩小字号高度以适配紧凑布局
2026-04-20 15:56:59 +08:00
fawney19 d100c934c0 fix(frontend): 优化 OAuth 身份徽章优先级并避免 Kiro 订阅标签重复
- getOAuthOrgBadge 优先展示 organization id, 其次回落到 account_id 或 account_user_id, 不再使用 account_name 作为徽章文案
- Kiro provider 的订阅标签仅在与 OAuth plan 标签不一致时显示, 避免重复徽章
- 补充 getOAuthOrgBadge 的单元测试覆盖组织优先与回落场景
2026-04-20 15:00:17 +08:00
fawney19 ffe5d16094 fix(oauth): 兼容 Kiro device token 的 snake_case 字段并保留 error 响应体
- device poll 同时接受 accessToken/access_token、refreshToken/refresh_token、expiresIn/expires_in
- post_kiro_device_oidc_json 在解析失败响应时保留完整 JSON 并附加 _error 标记, 避免丢失 authorization_pending 等详细信息
- 补充 authorization_pending 时 device session 保持 pending 状态的测试
2026-04-20 15:00:02 +08:00
Dalamud 078347b722 fix(aether-crypto): fix provider key decryption for legacy Python Fernet secrets (#316) 2026-04-20 14:31:54 +08:00
fawney19 9b48a008dc fix(frontend): 合并 OAuth 状态展示并移除 Codex 积分信息
- providerKeyStatus: 合并 snapshot 与 legacy OAuth 状态,按严重程度取优并补全失效原因
- ProviderDetailDrawer: 移除 Codex 积分摘要与相关字段显示
2026-04-20 14:27:48 +08:00
AAEE86 b2ff7d2c28 fix(wallet): 删除与过期删除 API Key 时同步禁用关联钱包 (#315)
- 在 delete_user_api_key 和 delete_standalone_api_key 事务中,先将关联 wallets.status 更新为 disabled
- 在过期 Key 自动清理流程中,删除前同步禁用对应钱包
- 保留 wallet 与交易流水,避免出现 orphaned 且 active 的钱包状态
2026-04-20 14:03:17 +08:00
fawney19 13bf222b8d refactor(oauth): 抽出 Kiro 刷新逻辑为共享模块并在 device-poll 复用
- 将 batch/kiro_import 中的 Kiro refresh/IDC helpers 抽到 dispatch/kiro.rs 共享
- device-poll 新增 IDC refresh 复核、JWT 缺失时通过 usage 接口回填 email、代理节点写入 key.proxy
- 补充对应集成测试覆盖 refresh 复核与 email 回填路径
2026-04-20 01:06:51 +08:00
fawney19 c2b7b6c231 refactor(proxy-nodes): 移除节点列表的 rollout 升级标签展示 2026-04-20 00:31:30 +08:00
fawney19 c1b9d94c84 feat(adaptive): 完善自适应 RPM 学习并将 Pool 调度状态与健康分解耦
- orchestration 新增 AdaptiveSuccess 效果,在成功回报路径上根据利用率窗口扩张 learned_rpm_limit
- 429 路径改用 429_observation/adjustment 记录以及基于历史的置信度评估,新增 last_rpm_peak 边界字段
- Pool 调度状态不再因 health_score 低或熔断而降级/拦截,前端同步移除相关按钮与文案兜底
- 新增前端 poolTrace 工具(附测试)承接原 HorizontalRequestTimeline 内的候选合并逻辑
2026-04-20 00:27:48 +08:00
fawney19 1f74e660de feat(transport): 暴露同格式 provider 的细分失败原因并在时间线展示
- claude_code/kiro/vertex policy 新增 transport_unsupported_reason_with_network 变体,返回具体失败码
- planner candidate_metadata 在 request_pair 写入同格式 provider 的细分 transport 原因
- 前端请求时间线优先展示 transport_diagnostics 的细分原因而非通用 transport_unsupported
2026-04-19 21:45:57 +08:00
fawney19 77aac74590 feat(oauth): 完善账号异常识别并在调度/展示层拦截失效 OAuth 密钥
- 新增 aether-admin provider status 模块,统一解析账号状态(禁用/工作区停用等)
- 调度器 runtime 增加 oauth_invalid 判定,跳过刷新失败或已撤销的 OAuth 密钥(REQUEST_FAILED 保留可选)
- gateway state 在 local oauth 刷新返回 4xx 时持久化失败原因并同步状态快照
- admin pool 列表/详情回填 account 状态与 scheduling 阻塞原因(account_blocked)
- 共享 catalog 的 status_snapshot payload 附加 account 字段
2026-04-19 20:50:31 +08:00
fawney19 d719a1329c fix(usage): cache-affinity 间隔查询将 interval_minutes 强制转为 DOUBLE PRECISION 2026-04-19 16:32:29 +08:00
fawney19 c302dfe42d fix(usage): 完善 cache-affinity 时间线的用户名回退与模型空值兜底
- 当 auth 用户查询失败时回退到历史 username,避免时间线丢失用户信息
- SQL 投影对 usage.model 使用 COALESCE 兜底空值
- IntervalTimelineCard 在刷新间隔为 0 时跳过定时刷新
- Usage 页面关闭时间线卡片的自动轮询
2026-04-19 16:07:01 +08:00
fawney19 41b51f10a9 perf: 并行化 admin 聚合路由并完善前端缓存预取
- gateway: usage detail / provider summary / pool overview / users list 改为 tokio join 并行拉取依赖数据
- usage: interval timeline 支持自动刷新并按查询区间动态展示,取消服务端 120 分钟过滤并在 ScatterChart 统一封顶
- frontend: 新增管理端导航预取工具及 SidebarNav/MainLayout 触发,admin 读接口统一走 cachedRequest 的短期缓存
- dashboard: request detail 支持短 TTL 缓存并在 UsageRecordsTable mousedown 时预取
- data: migrate 测试在 wait_for_postgres 失败时清理子进程,避免遗留
2026-04-19 15:17:25 +08:00
fawney19 97cd877ce5 style(proxy-nodes): 统一缩进并移除未使用的 confirmWarning 引用 2026-04-19 13:46:21 +08:00
fawney19 7007d7a556 refactor(proxy-nodes): 简化批量升级为直接写入 upgrade_to 目标
- 后端移除分波 rollout/探测逻辑,改为一次性给所有合格 tunnel 节点写入升级目标,并自动取消活动中的 rollout
- 前端移除 rollout 进度、阶段筛选、重试/跳过/冲突清理等相关 UI 和交互
- 同步更新批量升级接口类型与测试用例
2026-04-19 13:38:59 +08:00
fawney19 e6fd95453d test(proxy-nodes): 将离线隧道用例的连通性探测指向 Cloudflare 以避免环境抖动 2026-04-19 04:03:37 +08:00
fawney19 0329b7b784 feat(usage): 在 CanonicalUsage 中追加缓存 token 字段并在标准化流程透传
- 扩展 CanonicalUsage 支持 cache_creation/cache_read 及 5m/1h 明细
- OpenAI/Claude/Gemini 解析器提取缓存 token 并计入 total
- usage_mapper 补齐 Claude 缓存字段映射
- 新增 gateway pricing 集成测试覆盖三家同步/流式缓存计费
2026-04-19 03:55:55 +08:00
fawney19 2f487fda66 fix(usage): 修复缓存创建 token 展示并在总量中计入缓存组件
- gateway/admin 用量载荷在 cache_creation_input_tokens 为 0 时回填 ephemeral_5m/1h 合计
- 标准化用量写入时将 cache_creation/cache_read token 计入 total_tokens
- 前端列表与轮询同步新增分类字段,修复缓存 token 列显示
2026-04-19 02:46:28 +08:00
fawney19 e82c9c5104 fix(usage): 容错缺列投影以支持旧版和部分升级场景
map_usage_row 中对 http_audit/routing/settlement 等新增投影列改用
row_try_get_optional,将 ColumnNotFound 视同 NULL,避免仅选取核心字段
的读取路径在列缺失时整行失败。同时补充 UPSERT 和 LIST 前缀的占位列
断言。
2026-04-19 01:48:16 +08:00
github-actions[bot] c31261789c chore(proxy): update download links for proxy-v0.3.2 2026-04-18 17:30:37 +00:00
AAEE86 6666992d01 feat(admin): 将用户管理的用量统计改为全量累计口径 (#314)
- 新增按用户 ID 批量汇总 usage 的后端查询
- 在 /api/admin/users 中返回 request_count 和 total_tokens
- 移除用户管理页面额外的 usage 聚合请求
- 为管理端用户列表补充累计统计回归测试
2026-04-19 01:26:22 +08:00
fawney19 ab54881eb0 ci: 固定 macOS runner 版本并确保 Rust target 安装 2026-04-19 01:21:21 +08:00
fawney19 f8545c5141 chore: bump aether-proxy version to 0.3.2 2026-04-19 01:04:55 +08:00
AAEE86andfawney19 bafb63a665 持久化 provider key 使用统计并移除 usage 汇总覆盖 (#313)
* 持久化 provider key 使用统计并移除 usage 汇总覆盖

- 在 usage upsert 时按请求前后差值同步 provider_api_keys 统计
- 增加基于保留 usage 记录的 provider key 统计重建能力
- 号池管理列表直接读取 provider_api_keys 统计字段

* fix: harden provider key usage stats sync

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-19 01:02:09 +08:00
RWDaiandfawney19 425227509a Fix/provider query cli model tests (#312)
* fix(admin): support openai cli provider-query model tests

* fix(admin): support claude and gemini cli provider-query model tests

* fix(admin): preserve provider-query prompts on cli fallback

* test(usage): relax async status wait for local usage checks

* fix(gateway): align provider-query CLI auth and headers

* fix(gateway): resolve provider-query clippy lint

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-18 23:05:39 +08:00
fawney19 f2a3836877 Improve tunnel proxy diagnostics and request body spooling 2026-04-18 21:13:37 +08:00
fawney19 3363592751 Refactor usage body capture and stream terminal reporting 2026-04-18 17:48:21 +08:00
fawney19 569242d72f refactor gateway orchestration and failover effects 2026-04-18 11:35:11 +08:00
fawney19 3321bb3ccc Implement independent provider pool scheduling runtime 2026-04-17 23:05:49 +08:00
RWDaiandfawney19 dd4641d618 Fix/provider query non kiro tests (#311)
* fix(admin): enable local provider-query tests for non-kiro providers

* test(admin): cover non-kiro provider-query model execution

* fix(admin): preserve provider-query test errors and failover retries

* fix(admin): handle provider-query test alias and HTTP retry edges

* fix(admin): extend provider-query failover coverage and fallback behavior

* fix(admin): prefer supported endpoints for provider-query tests

* fix(admin): prefer provider-query endpoints with compatible keys

* fix(admin): fall back to compatible provider-query endpoints

* fix(admin): align provider-query local tests with transport policy

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 19:45:20 +08:00
fawney19 0ce61bc91c fix(gateway): align provider restrictions with provider catalog 2026-04-17 18:59:13 +08:00
fawney19 cb647d95f9 Hide format_conversion_disabled skips when exact-format candidate shares the same key
Also switch failover test to surface an auth failure instead of a 502 upstream error.
2026-04-17 18:24:36 +08:00
fawney19 7eae1f90f6 Unify quota snapshots and oauth refresh handling 2026-04-17 18:22:41 +08:00
RWDaiandfawney19 b8702ae124 Fix/api key concurrency runtime miss (#309)
* test(cli): 覆盖 API key 并发等待与超时路径

* feat(scheduler): API key 并发饱和时等待可用槽位

* fix(proxy): 区分 API key 并发受限与真正的 runtime miss

* fix(outcome): runtime miss 仅归因真实执行候选

* feat(api-keys): 统一 concurrent_limit 默认值与校验辅助

* feat(admin): 独立 Key 接口支持 concurrent_limit

* feat(admin): 用户 API Key 路由支持 concurrent_limit

* feat(public): 自助 API Key 路由支持 concurrent_limit

* feat(import): 导入与存储层持久化 concurrent_limit

* feat(frontend): 同步 API Key concurrent_limit 类型定义

* feat(frontend): 独立 Key 表单支持 concurrent_limit

* feat(frontend): 管理员用户 API Key 表单支持 concurrent_limit

* feat(frontend): 自助 API Key 页面支持 concurrent_limit

* chore(fmt): 统一 runtime 归因相关 Rust 格式

* chore(fmt): 统一 admin API key 路由 Rust 格式

* chore(fmt): 统一 public 路由与相关测试 Rust 格式

* fix(test): 对齐 no-execution usage 归因断言

* test(middleware): 固定 access log tracing 用例线程模型

* fix(frontend): 提取用户 API Key payload 默认并发辅助

* fix(frontend): 保留用户 Key 的 concurrent_limit 默认值

* fix(api-keys): remove hardcoded concurrent limit default

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 14:21:43 +08:00
AAEE86andfawney19 e5d3722adf fix(admin): force manual oauth refresh to bypass local cache (#308)
Co-authored-by: fawney19 <[email protected]>
2026-04-17 14:12:15 +08:00
RWDaiandfawney19 ff4e853fd3 Fix/usage transfer filter (#307)
* fix(usage): 恢复 usage 列表中的 fallback 路由信号

* fix(admin): 支持 usage 记录展示和筛选 fallback 转移

* fix(usage): 在用户 usage 记录中暴露 fallback 标记

* fix(usage): 共享 usage 页面支持 fallback 筛选

* fix(usage): 对齐 fallback 筛选相关前端类型

* fix(usage): propagate has_fallback through active polling

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 13:38:23 +08:00
fawney19 654a41c3b0 Revert "fix(admin): force manual oauth refresh to bypass local cache"
This reverts commit 85a630cfa9.
2026-04-17 13:36:51 +08:00
Entropy.Xuandfawney19 c6af4791f8 fix(oauth): 对齐 OpenAI 回调 state 解析与提交流程 (#306)
Co-authored-by: fawney19 <[email protected]>
2026-04-17 13:26:23 +08:00
fawney19 85a630cfa9 fix(admin): force manual oauth refresh to bypass local cache 2026-04-17 13:23:51 +08:00
Entropy.Xuandfawney19 ac1a126756 fix(kiro,pool,model): 对齐 Kiro 管理链路并修复全局模型删除行为 (#305)
* feat(pool): 号池支持跳过额度耗尽账号

- 新增 pool_advanced.skip_exhausted_accounts 开关及高级设置 UI, 默认关闭并兼容旧配置
- 为 Codex/Kiro 增加额度耗尽判定, 接入请求侧候选跳过并新增 account_quota_exhausted skip reason
- 号池列表将额度耗尽账号标记为 blocked/额度耗尽, 并补充前后端相关测试

* fix(kiro): 对齐账号管理与 provider-query 的 Rust 行为

- 修复 Kiro 单条导入误走 import-refresh-token 的前端分流, 并为误用路径返回明确错误提示
- 为 Kiro 导入与本地请求链补齐 bearer 兼容, 同步放开账号启停等 Key 更新操作的 auth_type 校验
- 实现 Kiro provider-query 本地模型测试与 failover 执行链, 并修复结果弹窗在无 trace 时无法展示 attempts/响应体的问题

* fix(model): 删除全局模型时级联清理关联提供商模型

- 对齐 Python 版本删除逻辑, GlobalModel 删除前先在事务内清理关联的 Provider Model 记录
- 修复已绑定 Provider 的模型在 Rust SQL 仓库下会被外键约束拦住、无法正常删除的问题
- 增加管理端回归测试, 覆盖绑定 Provider Model 的 GlobalModel 删除场景

* fix(kiro,ci): 恢复 Kiro OAuth 持久化并修复 Rust CI

* Fix oauth-managed provider key semantics

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 12:57:06 +08:00
RWDaiandfawney19 96a25d058b Fix OpenAI family local auth to use bearer (#302)
* Fix OpenAI family local auth to use bearer

* test(gateway): fix bearer auth assertions for openai local flows

* test(usage): make local usage status wait resilient

* style(gateway): apply rustfmt to usage test helper

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 11:10:05 +08:00
Entropy.Xuandfawney19 6964729cb7 feat(payments): 增加兑换码与支付适配框架 (#299)
* feat(payments): 增加兑换码与支付适配框架

* fix(ci): 对齐 Rust 1.95 lint 与格式要求

* fix(payments): harden redeem code wallet credits

---------

Co-authored-by: fawney19 <[email protected]>
2026-04-17 10:07:52 +08:00
fawney19 54d77598ae ci(rust): 将环境变量提升至全局,各 job 添加 toolchain 显示步骤 2026-04-17 01:58:12 +08:00
fawney19 0bcfc7d352 fix(ci): 将 rustfmt/clippy 组件安装改为独立 rustup 步骤 2026-04-17 01:53:59 +08:00
fawney19 faaaec28e1 fix(clippy): 修复冗余 into_iter、sort_by 及 match 嵌套 if 等 lint 警告 2026-04-17 01:43:45 +08:00
fawney19 dde02e6111 fix(ci): 恢复 rust-toolchain@stable,版本由 rust-toolchain.toml 控制 2026-04-17 01:17:29 +08:00
fawney19 aadc6b665c chore: 锁定 Rust 版本为 1.95.0,统一本地与 CI 环境 2026-04-17 01:15:16 +08:00
fawney19 6730e821b2 fix(clippy): 修复 collapsible_match 和 unnecessary_sort_by 警告 2026-04-17 01:12:43 +08:00
fawney19 05ab09c469 fix(data): 修复 global_models 插入时缺少 usage_count 字段导致的列数不匹配问题 2026-04-17 01:09:14 +08:00
fawney19 1e0bc61526 feat(gateway/data): 新增 usage 关键词搜索、缓存命中摘要、结算成本摘要及 dashboard 聚合查询能力
- 新增 UsageAuditKeywordSearchQuery,支持多关键词、用户名、API Key 交叉过滤
- 新增 UsageCacheHitSummaryQuery/StoredUsageCacheHitSummary,统计请求缓存命中率
- 新增 UsageSettledCostSummaryQuery/StoredUsageSettledCostSummary,汇总结算成本
- 新增 UsageDashboardSummaryQuery、UsageBreakdownSummaryQuery 等 dashboard 聚合类型
- SQL 层实现对应查询方法,含 list_by_ids、list_usage_audits_by_keyword_search、count_usage_audits_by_keyword_search
- 将上述能力通过 GatewayDataState / AdminAppState 暴露给 handler 层
- user_me_usage 及 dashboard_filters 切换为新查询接口,移除旧的内存过滤逻辑
- 同步更新 memory 层及测试
2026-04-17 01:00:38 +08:00
github-actions[bot] 6e5af5ef70 chore(proxy): update download links for proxy-v0.3.1 2026-04-16 12:41:35 +00:00
fawney19 d312c397e1 chore: bump aether-proxy version to 0.3.1 2026-04-16 20:34:02 +08:00
fawney19 0dce667019 feat(tunnel/usage): proxy writer 双优先级队列、hub 连接压力感知选择、usage 请求记录级别控制及 trace 页面 proxy timing 增强 2026-04-16 20:14:50 +08:00
fawney19 65cd9dc3e5 fix(admin): 修复 usage 观测全表扫描并下推聚合查询
- 收紧 admin usage/stats 默认时间范围和 active 轮询查询
- 将 summary/records/aggregation/time-series/leaderboard 下推到 SQL 侧
- 统一前端时间参数并补齐 admin usage/stats 回归测试
2026-04-16 17:46:19 +08:00
AAEE86 10605d9fb0 fix(admin): 同步 OAuth 刷新后的状态快照有效期 (#300)
手动刷新 OAuth Token 后同步回写 status_snapshot.oauth
修复号池管理页面仍显示旧有效期的问题
补充刷新后快照更新的回归测试
2026-04-16 14:37:59 +08:00
AAEE86 af9711c2a2 fix(pool): 修复 Codex 配额倒计时不准确 (#297)
* fix(pool): 修复 Codex 配额倒计时不准确

- 后端号池 keys payload 增加 upstream_metadata 透传
- 前端补充 PoolKeyDetail.upstream_metadata 与 Codex reset_after_seconds 类型
- 号池页倒计时优先使用 reset_at/reset_seconds/updated_at 结构化数据计算
- 仅在缺少结构化字段时回退 account_quota 文案解析

* fix(pool): 将已重置的 Codex 配额恢复为 100%

- reset_after_seconds/reset_seconds 会按 updated_at 扣减经过时间(不再当作静态值)
- 若窗口已重置(剩余秒数 <= 0),该窗口 used_percent 按 0 处理
2026-04-16 13:34:41 +08:00
fawney19 9a41b2c0dc feat: 候选排序引入 API 格式偏好, 追踪页面展示完整格式转换信息
- 候选排序优先同 kind (chat/cli) 再同 family, 替代原有固定顺序
- 不再隐藏 format_conversion_disabled 候选, 保留完整追踪链路
- DecisionTrace 新增 provider/endpoint/key 格式转换相关字段
- 前端追踪面板新增 Key 支持端点和转换策略展示
2026-04-16 13:00:44 +08:00
fawney19 d805a28c9a feat: 请求候选追踪添加 proxy 元数据, 修复 usage 状态回退, 优化前端轮询
- 在各 planner decision payload 中注入 proxy trace 信息 (node_id, node_name, url, source)
- request_candidate 报告上下文支持 proxy 字段, extra_data 合并逻辑改为 merge 而非覆盖
- SQL/内存仓库防止 usage status 从 streaming 回退到 pending
- 前端移除活跃请求完成时的全表刷新, active discovery 尊重 globalAutoRefresh 开关
2026-04-16 11:57:42 +08:00
fawney19 ffe34120c1 tune(tunnel): 放宽 ping 间隔和超时参数, 增大出站队列容量
- gateway ping 间隔 500ms -> 15s, 出站队列 128 -> 512
- proxy ping 间隔 1s -> 10s, 连接超时 1.5s -> 3s, stale 超时 5s -> 30s
2026-04-16 11:05:44 +08:00
fawney19 47a11ee0b5 feat(tunnel): 添加流帧分发超时机制和结构化请求日志
- dispatcher: 为 stream handler 的帧接收添加超时保护, 防止单个
  handler 阻塞 WebSocket 读循环; 超时后发送 StreamError 并清理流
- stream_handler: 在所有请求完成和错误路径添加结构化日志, 记录
  method/host/path/status/duration 等关键信息
- 将常规隧道连接/断开日志从 info 降级为 debug, 减少日志噪音
2026-04-16 10:32:05 +08:00
fawney19 28a489acbe fix(tunnel): writer 退出时立即标记连接关闭, 防止后续请求堵塞
- ws_tx.send 添加 15s 超时, 防止 TCP 背压导致 writer 无限阻塞
- writer 退出时立即 request_close(), 不等 reader 结束即从路由表摘除连接
- ws_tx.close 添加 5s 超时, 防止 close 握手阻塞
2026-04-16 02:29:36 +08:00
fawney19 7597caa3a5 修改日志格式 2026-04-16 02:13:27 +08:00
fawney19 586d2cc42b fix(tunnel): 为 hub reader body 推送添加超时, 防止流间 head-of-line blocking
push_body_chunk 原先使用无超时的 channel send, 当消费端慢时会阻塞
整条 proxy 连接的 reader, 导致同连接上其他 stream 的帧无法路由。
添加 5 秒超时后, 单个 stream 的背压不再影响其他 stream。
2026-04-16 02:11:32 +08:00
fawney19 1fd1f8216d fix(gateway): 补充 example 中缺失的 idle_timeout 字段 2026-04-16 01:14:49 +08:00
fawney19 0c3f730852 fix(testkit): 补充 TunnelConnConfig 缺失的 idle_timeout 字段 2026-04-16 01:09:53 +08:00
fawney19 b678132176 fix(tunnel): 默认禁用 proxy idle timeout (设为 0)
保留 idle timeout 机制但默认不启用,避免误断连。
2026-04-16 00:56:24 +08:00
fawney19 e4be1d6b56 Revert "refactor(tunnel): 移除 proxy 连接 idle timeout 机制"
This reverts commit b004551fe5.
2026-04-16 00:53:40 +08:00
fawney19 5f0fba1807 diag(tunnel): writer/reader 帧计数诊断日志
- writer task 记录发送帧数、Binary 帧大小、send 失败原因
- reader 记录接收帧总数,disconnect 时输出
- 用于定位 gateway→proxy 数据流是否正常
2026-04-16 00:31:10 +08:00
fawney19 205f78b39c style: cargo fmt 2026-04-15 23:54:14 +08:00
fawney19 5aa8883865 fix(tunnel): gateway 回复 WebSocket Pong 防止 proxy stale 断连
gateway reader 之前用 `_ => {}` 忽略了 proxy 发来的 WebSocket Ping,
导致 proxy 的 stale_timeout (5s) 持续触发断连重连循环,
所有隧道请求都因等待 response headers 超时而失败。

同时补充 hub 诊断日志和连续请求集成测试。
2026-04-15 23:45:22 +08:00
fawney19 1e2c3fc4fc fix(tunnel): 移除 testkit 和 example 中残留的 idle_timeout 引用 2026-04-15 22:18:41 +08:00
fawney19 b004551fe5 refactor(tunnel): 移除 proxy 连接 idle timeout 机制
idle timeout 在实际使用中容易误断活跃连接,移除该逻辑并简化 reader 循环。
2026-04-15 22:10:30 +08:00
fawney19 704858390d feat(gateway): 流式执行支持 local tunnel 传输并改进 SSE 错误通知
- 流式执行优先尝试 local tunnel 路径,不可用时降级到直连
- stream_pump 适配 Reqwest 和 LocalTunnel 双响应类型
- SSE passthrough 流中断时向下游发送 aether.error 终端事件
- 提取 frame 编码辅助函数消除重复代码
- 新增本地隧道流式场景的集成测试
2026-04-15 21:10:16 +08:00
fawney19 c569081340 fix(usage): streaming 状态在首字节到达前显示为 pending
新增 resolveDisplayRequestStatus 函数,当记录状态为 streaming 但
first_byte_time_ms 为空时回退显示为 pending,避免误导用户。
2026-04-15 17:50:45 +08:00
fawney19 77d413d777 tune(tunnel): 调整 tunnel 超时与连接池参数,增加冗余连接下限
- Gateway: 增大 idle timeout 至 2s、ping interval 至 500ms
- Proxy: 统一 reconnect/ping/stale 超时为常量,降低 reconnect_max 至 250ms
- 自动连接池引入 redundant floor(2),保证低负载时也有冗余连接
2026-04-15 16:27:42 +08:00
fawney19 43e7ad112f perf(usage): user-me usage 查询避免全量加载,默认限制 7 天范围
- UsageAuditListQuery 新增 limit 字段,支持在 SQL 层 LIMIT
- user-me usage 端点无时间范围时默认查最近 7 天
- active usage 端点无 ids 过滤时限制查最近 1 小时
- InMemory 实现同步支持 limit truncate
2026-04-15 16:25:57 +08:00
fawney19 fbb8249c0d fix(gateway): 改进流式传输稳定性
- stream_pump 读取错误时记录完整错误链并输出 warn 日志
- hub body 转发从 try_send 改为 async send,支持背压避免丢帧
- tunnel_stale_timeout 默认值从 900ms 提升到 10s,减少误判过期
2026-04-15 14:26:57 +08:00
fawney19 707d9ac274 perf(gateway): 流式处理代码层性能优化
- 将 spawn 内 buffered_body 从 Vec<u8> 改为 VecDeque<u8>, drain 前端从 O(n) 变 O(1)
- 消除 prefetched body/chunks 的不必要 clone, 改为直接 move
- Pending/Streaming 非终态候选状态写入改为 tokio::spawn fire-and-forget
- 连接池默认 max_connections 从 30 提升到 50
2026-04-15 11:55:45 +08:00
fawney19 026a77306c fix(schema): 移除 api_keys.concurrent_limit 的默认值 5
之前 DEFAULT 5 导致所有新建 API Key 自动带上并发限制,
改为 NULL (不限制) 以匹配预期行为。
2026-04-15 10:43:38 +08:00
fawney19 4087e096f2 fix(usage): 为 InMemory 仓库补充 status 过滤逻辑 2026-04-15 09:53:52 +08:00
fawney19 8827c46c33 perf(usage): 将 status 过滤下推到 SQL 查询层
为 UsageAuditListQuery 新增 statuses 字段,支持在数据库端按状态
筛选用量记录。admin usage summary 路由不再全量拉取后内存过滤,
改为直接查询 pending/streaming 状态的记录。
2026-04-15 09:30:42 +08:00
fawney19 adde9ff237 chore(proxy): bump version to 0.3.0 2026-04-15 08:46:19 +08:00
fawney19 cfb4f4582b perf(gateway): 限制流式响应体缓冲区大小为 256KB
流式转发过程中 provider 和 client 的 body buffer 可能无限增长,
对长响应造成内存压力。添加 256KB 上限, 超出时截断早期数据,
并在上报日志中跳过已截断的 body 以避免输出不完整内容。
2026-04-15 08:46:19 +08:00
github-actions[bot] 8bb637962f chore(proxy): update download links for proxy-v0.3.0 2026-04-14 19:11:01 +00:00
fawney19 67a2ca6e31 perf(gateway): 为 dashboard 接口添加短时响应缓存
新增 DashboardResponseCache,对 stats/daily_stats/provider_status
三个接口按用户维度缓存 15-30 秒,减少重复数据库查询。
2026-04-15 03:03:20 +08:00
fawney19 98ad1172b0 perf(usage): heatmap 改为数据库端按天聚合查询
将 admin 和 user heatmap 从逐条加载 usage audit 记录后在应用层聚合,
改为通过 SQL GROUP BY DATE 在数据库端直接按天汇总, 大幅减少数据传输量。

新增 UsageDailyHeatmapQuery / StoredUsageDailySummary 类型,
在 trait、SQL、内存实现中均补齐 summarize_usage_daily_heatmap 方法。

同时优化 docker-compose: postgres 增加空闲事务超时与 keepalive 参数,
gateway 增加健康检查配置。
2026-04-15 02:11:32 +08:00
fawney19 05fbbac493 feat(proxy): 支持配置 private 目标地址放行 2026-04-15 00:31:36 +08:00
fawney19 a4e7ac1df6 feat(proxy): 重构 Proxy 节点管理与隧道系统
- 重构 proxy_nodes 管理端,支持节点注册、心跳、隧道生命周期管理
- 增强 tunnel 嵌入式 hub 和隧道协议
- 重构 aether-proxy 配置、隧道客户端、心跳和调度机制
- 调整 admin OAuth/配额/导入等处理器的参数传递
- 扩展数据迁移模块
- 补充 proxy nodes、OAuth、配额、系统导入等测试
- 更新前端 proxy nodes 视图和 API
2026-04-14 22:51:02 +08:00
AAEE86 fb31928e44 fix(mapping): 对齐全局模型映射的正则匹配行为与范围 (#296)
- scheduler_core: `matches_model_mapping` 改为大小写不敏感且整串匹配,并补充单测
- global model routing 预览:
  - Key 过滤增加 `allowed_models + model_mappings` 校验
  - `all_keys_whitelist` 改为收集全站活跃 Provider 的活跃 Key 白名单
- provider mapping-preview:
  - 优先使用 admin 全量 GlobalModel(含非激活)参与映射
  - admin 数据为空时回退 public 模型,保持兼容
- public models 匹配逻辑统一复用 scheduler_core 实现,避免行为分叉
- 更新网关测试,覆盖未关联 Provider 的 Key 也进入 whitelist 的场景
2026-04-14 21:58:52 +08:00
fawney19 47bf1d04a1 fix(admin): 补齐 key 自动获取模型的即时刷新场景 (#295)
- 新增 key 且开启自动获取时立即抓取并写回 allowed_models
- 自动获取已开启时修改包含/排除规则后立即刷新 allowed_models
- 补充创建与过滤规则变更场景的控制层回归测试
2026-04-14 14:56:22 +08:00
AAEE86 b70f32c6c2 fix(dashboard): 使用有效输入口径展示今日 Token (#294)
将仪表盘“今日 Token”卡片中的输入项改为有效输入口径。
对 OpenAI/Gemini 从 input_tokens 中扣除 cache_read_tokens,
Claude 保持原始输入口径不变。

同步更新 dashboard 集成测试,覆盖 OpenAI 与 Claude 的差异化行为。
2026-04-14 14:53:59 +08:00
fawney19andAAEE86 21ac1825f3 fix(gateway): 修正 Codex 实时配额同步
Close #293

Co-authored-by: AAEE86 <[email protected]>
2026-04-14 14:51:26 +08:00
AAEE86 0081622f90 fix(gateway): 补齐 Codex 普通请求的实时配额同步
在 usage reporting 的 sync/stream 终态处理链中补充 Codex 配额回写,
将普通请求响应头中的 x-codex-* 实时同步到 key.upstream_metadata.codex。

- 复用 parse_codex_usage_headers 解析配额头
- 仅对 codex provider 执行 best-effort 回写
- 增加 30s TTL 指纹去重,忽略 reset 倒计时等波动字段
- 补充 realtime helper 单测与 sync/stream 集成测试
2026-04-14 14:40:48 +08:00
AAEE86 d089ed22c7 fix(admin): 补齐 key 自动获取模型的即时刷新场景
- 新增 key 且开启自动获取时立即抓取并写回 allowed_models
- 自动获取已开启时修改包含/排除规则后立即刷新 allowed_models
- 补充创建与过滤规则变更场景的控制层回归测试
2026-04-14 14:33:45 +08:00
fawney19 861ae81ff0 feat(admin): 完善代理节点与 OAuth 授权管理 2026-04-14 14:09:24 +08:00
fawney19 593640ac19 feat(gateway): 增强候选路由策略与可观测信息 2026-04-14 11:50:52 +08:00
fawney19 62e0a0338d refactor: 移除 shadow results 相关模块和接口 2026-04-14 09:46:16 +08:00
fawney19 5fd3240fcf fix(admin): 同步 key 自动获取模型与相关测试
Closes #292
Co-authored-by: AAEE86 <[email protected]>
2026-04-14 02:34:36 +08:00
AAEE86 563dd44957 test(gateway): 稳定本地 usage 终态断言
- 为 usage local 测试添加 wait_for_usage_status 辅助方法
- 轮询直到 usage 记录进入 completed/failed 终态
- 避免异步 usage runtime 先落 pending/streaming 导致测试抖动
2026-04-14 02:22:23 +08:00
AAEE86 23233a3243 fix(admin): 修复 Key 自动获取模型时 allowed_models 同步逻辑
- 关闭自动获取上游模型时清空 allowed_models
- 开启自动获取上游模型时立即拉取并覆盖 allowed_models
- 增加模型覆盖提示并补充相关回归测试
2026-04-14 02:21:46 +08:00
fawney19 1000b706be fix(gateway): 收紧本地 usage 测试轮询条件 2026-04-14 01:55:13 +08:00
fawney19 53acfbabf6 refactor(gateway): 重构 ai pipeline 规划链路 2026-04-14 01:27:04 +08:00
RWDai 37bb120d20 feat(aether-proxy): 支持 Alpine 主机服务安装与 musl 发布 (#291)
* feat(aether-proxy): 支持 Alpine 主机服务安装与 musl 发布

* fix(aether-proxy): address alpine support review findings
2026-04-13 17:20:22 +08:00
fawney19 4fd2b4a014 fix(gateway): 修复 balance 刷新去重键冲突、usage 状态回退与测试竞态
- balance_cache: 引入实例级 refresh key 防止多实例共享进程级 HashSet 冲突
- InMemoryUsageRepo: 阻止 pending/streaming 状态覆盖已终结(completed/failed/cancelled)记录
- usage 同步测试: 等待条件从 is_some() 改为检查 status=="completed" 避免竞态
- wallet 测试: 增加轮询等待 wallet 扣款完成
- 整理 import 语句与 tests 模块位置
2026-04-13 16:48:05 +08:00
fawney19 9a376e4223 refactor(admin): 将排行榜排序与排名函数移至 tests 模块之前 2026-04-13 16:13:18 +08:00
AAEE86 b2d85d70ca refactor(runtime): 优化管理端摘要查询与维护聚合链路
- 为 provider catalog key 和 video task 列表增加 summary/page 查询与排序能力,减少列表场景读取重字段
- 将多处 SQL 结果读取改为流式收集,降低 `fetch_all` 的内存占用
- 把日/小时统计、钱包日用量等维护任务改为数据库侧 `CTE + upsert` 聚合
- 修复视频任务轮询更新时从本地 snapshot 回填稀疏字段,避免 `prompt` 和请求体信息丢失
2026-04-13 15:44:58 +08:00
fawney19 6aa16ec792 feat(data): 废弃 usage 表 HTTP/结算列,迁移至 settlement_snapshots 与 http_audits
- 新增迁移 20260413030000:标记 billing_status、finalized_at、request_headers 等列为 DEPRECATED
- 更新 baseline_v2.sql 同步废弃注释,BASELINE_V2_CUTOFF_VERSION 升至 20260413030000
- usage/sql.rs:inline body 阈值归零,强制所有 body 走 blob 存储;upsert 时清空 legacy header/output_price 列
- 查询层优先读 usage_settlement_snapshots 的 billing_status、finalized_at、output_price_per_1m
- runtime.rs:stale usage 处理同步写入 usage_settlement_snapshots;SELECT FOR UPDATE 改为 FOR UPDATE OF usage
- 前端:PerformanceAnalysis 页面重构为实时面板,新增 prometheus 工具函数与 monitoring API
2026-04-13 14:53:46 +08:00
fawney19 e46629d11a fix(frontend): 统一 Usage 页面默认时间范围为 today 2026-04-13 14:14:07 +08:00
fawney19 5bb08e6aa4 feat(gateway): 重构 usage 数据层、迁移系统与系统导入
数据库迁移:
- 引入 baseline v2 bootstrap,空库首次启动自动初始化
- 服务启动不再自动执行迁移,需显式 `--migrate` 运行
- 新增 pending migration 检测,schema 落后时拒绝启动

Usage 数据层:
- usage body 存储外部化为独立 blob 表
- 新增 HTTP audit 表拆分存储请求/响应头与 body ref
- 后台清理任务支持 legacy body ref 元数据迁移
- usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈)

系统导入/导出:
- 支持用户、API Keys、钱包数据的完整导入
- 兼容 legacy 与 v1.3+ 两种导出格式

其他改进:
- executor outcome 增加 runtime miss 诊断上下文
- 主 tokio runtime 栈大小调整为 8MB
- 前端 provider 管理支持 base URL 配置
- dev.sh 支持 --migrate 参数
2026-04-13 14:01:22 +08:00
fawney19 3698e5a833 fix(test): 修复 MutexGuard 跨 await 点的 Clippy 警告 2026-04-12 16:22:29 +08:00
fawney19 f84febbf89 fix(proxy): 修复 Clippy dead_code 与 too_many_arguments 警告 2026-04-12 16:19:23 +08:00
fawney19 e029012f73 Merge pull request #289 from AAEE86/rust
fix(usage): 统一使用记录与仪表盘的缓存命中率计算口径
2026-04-12 16:12:22 +08:00
fawney19 9703840a36 feat(proxy): 实现代理节点批量升级回滚、隧道重定向跟随及远程配置管理
核心功能:
- 新增代理节点批量升级回滚工作流,支持分批升级、健康探针、跳过/重试/取消等操作
- proxy 隧道流处理器支持 HTTP 重定向跟随(最多 10 跳),区分 307/308 可重播与不可重播请求体
- proxy 协议新增 follow_redirects / http1_only 字段,网关侧同步支持
- 新增代理节点远端配置变更接口(名称、允许端口、调度状态、升级目标等)
- 新增代理节点注册/反注册/心跳的 Admin API,及节点过期清理维护任务
- gateway 隧道 owner-relay 支持流式代理大请求体,新增 5 MiB 默认限制
- 新增 ProxyNodeRegistrationMutation / ProxyNodeRemoteConfigMutation 数据类型
- proxy 配置新增重定向重播预算、心跳间隔等参数,TUI 安装向导同步更新
- 前端 ProxyNodes 页面新增批量升级操作面板及滚动进度展示
2026-04-12 16:02:38 +08:00
AAEE86 c24a29fa65 fix(model-fetch): 提取获取计划请求,并使网关运行时与共享构建器保持一致
- 将 build_execution_plan 的散参数收敛为 ModelFetchExecutionPlanRequest,消除 clippy too_many_arguments
- 在 aether-gateway 的 model_fetch runtime 中显式依赖 build_models_fetch_execution_plan
- 补充共享 models fetch plan builder 的运行时测试覆盖
2026-04-12 11:57:06 +08:00
AAEE86 3fcb2b1514 fix(dashboard): 修复今日统计口径并对齐每日统计日期显示
- 前端请求 /api/dashboard/stats 时传递 timezone 和 tz_offset_minutes
- 后端仪表盘汇总过滤 pending/streaming 和占位 provider,修正今日请求/Token/费用统计
- 今日 Token 卡片增加 K/M 单位显示,并补充写缓存/读缓存 Token 信息
- 修复每日统计 YYYY-MM-DD 被按 UTC 解析导致的“今天/昨天”串天问题
- 补充前后端回归测试,覆盖统计口径和日期解析场景
2026-04-12 11:20:39 +08:00
AAEE86 ab82841426 feat(model-fetch): 对齐 Rust 上游模型抓取行为到 Python 语义
将 Rust 版上游可用模型抓取逻辑收敛到 Python 版行为,统一后台自动抓模
与管理员 provider-query 的模型发现路径,消除标准 /models、固定模型目录、
Antigravity、Vertex AI 等 provider 在两端实现上的分叉。

核心变更:
- 在 aether-model-fetch 中引入统一抓模策略层
- 覆盖标准 /models、Vertex API Key、Vertex Service Account、
  Antigravity fetchAvailableModels、固定模型目录五类抓模路径
- 将 provider-query 与后台自动抓模都切换到共享抓模入口,避免重复拼接
  URL、headers 和 provider 特判逻辑

标准模型抓取对齐:
- 按 Python 语义调整抓模优先级:
  openai:chat > openai:cli > openai:compact
  claude:chat > claude:cli
  gemini:chat > gemini:cli
- 从抓模候选中移除 openai:responses
- 为 openai:cli/openai:compact、claude:cli、gemini:* 补齐 Python 同款
  User-Agent / 浏览器指纹请求头
- Claude 抓模保留 after_id 分页语义
- Gemini 抓模统一为 v1beta/models?key=... 语义

provider-query 对齐:
- 返回结果改为按 model id 聚合,并合并/排序 api_formats
- 最终模型列表按 model id 排序,行为与 Python 保持一致
- 固定目录 provider(codex/kiro/claude_code/gemini_cli)不再依赖活跃
  endpoint,即使无 endpoint 也能返回预设模型目录
- Antigravity 多 key 查询改为按账户可用性 + tier 排序,首个成功结果即
  停止,并接入 provider 级缓存
- 仅配置 openai:responses 的 provider 不再被视为抓模成功路径

自动抓模对齐:
- 自动抓模成功时写入 allowed_models、upstream_models cache,并同步
  upstream_metadata
- upstream_metadata 合并逻辑对齐 Python,对 quota_by_model 做模型级合并,
  并保留已有 reset_time
- 自动抓模失败时不覆盖已有 allowed_models
- 固定目录 provider 在无 endpoint 场景下也可成功更新 allowed_models

Antigravity 对齐:
- 使用 POST /v1internal:fetchAvailableModels 抓取可用模型
- 按 Python 规则处理 URL fallback 和 429/404/408/5xx fallback 状态
- 强制要求 auth_config.project_id
- 过滤 Python 黑名单模型
- 解析并持久化 upstream_metadata.antigravity.quota_by_model

Vertex AI 对齐:
- API Key 模式仅抓取 publishers/google/models
- Service Account 模式新增 JWT token exchange,并按 Python region 顺序
  抓取 google + anthropic publishers
- 模型 owned_by / display_name / api_format 推断与 Python 对齐
- 软 404 处理行为与 Python 收敛

Gemini CLI / 固定目录对齐:
- Gemini CLI 改为返回 Python 预设模型目录
- 在可用时通过 loadCodeAssist 补充 plan_type/project_id 元数据
- Codex/Kiro/Claude Code 改为共享固定模型目录实现

测试:
- 扩展 aether-model-fetch 单元测试,覆盖格式优先级、openai:responses 排除、
  请求头、Claude 分页、Gemini query auth、固定目录与 metadata 合并
- 调整 provider-query 控制面测试到 Python 语义
- 新增自动抓模运行时测试,覆盖固定目录成功、Antigravity metadata 合并、
  失败保留旧 allowed_models

验证:
- cargo nextest run -p aether-model-fetch --lib
- cargo nextest run -p aether-gateway control::admin::provider_query model_fetch::runtime::tests
2026-04-12 10:14:29 +08:00
AAEE86 def8135118 fix(compact): 移除 OpenAI Compact 请求中不受支持的 store 参数
- 在 OpenAI Compact 请求规范化流程中剥离 `store`
- 将 compact 默认 body rules 与 Codex CLI 默认规则拆分
- 补充 same-format 和转换链路的 compact 回归测试
2026-04-12 01:52:25 +08:00
AAEE86 335e440cc5 fix(usage): 统一使用记录与仪表盘的缓存命中率计算口径
- 新增归一化总输入上下文计算逻辑,按 provider 区分 OpenAI/Gemini 与 Claude 的 cache token 语义
- 将管理端使用聚合、用户使用记录、仪表盘缓存统计、缓存亲和性分析统一为 token 级缓存命中率
- 修正 total_input_context 字段,避免 cache_read 在部分 provider 上被重复计入分母
- 同步更新相关 Rust 单元测试与网关集成测试断言
- 调整前端 dashboard mock 中 cache_hit_rate 的单位为百分比
2026-04-12 00:57:40 +08:00
fawney19 7c5bb7f383 fix(admin): 修复系统配置导入的多项兼容性问题
- 引入 serde_path_to_error,反序列化失败时返回精确字段路径
- 为所有浮点字段新增数字字符串兼容反序列化器,支持 Python 序列化格式
- 修复 OAuth provider key 导入:正确写入加密的 api_key 和 auth_config
- 优化代理节点跳过提示,区分手动 URL 代理与 node_id 引用代理
- 新增测试覆盖上述场景
2026-04-11 23:10:25 +08:00
fawney19 a9f610fa69 feat(admin): 实现系统数据导入导出功能,支持提供商和模型批量配置 2026-04-11 21:39:04 +08:00
fawney19 801e16c988 refactor(gateway): 统一 AETHER_GATEWAY_BIND 为 APP_PORT,新增 API Key 前缀配置和启动自举管理员
- 绑定地址固定 0.0.0.0,仅通过 APP_PORT 控制端口,简化 CLI/Docker/systemd/dev.sh/前端代理全链路
- 新增 API_KEY_PREFIX 环境变量,抽取 handlers/shared/api_keys.rs 消除 admin/public 重复逻辑
- 新增 bootstrap_admin.rs,启动时通过 ADMIN_* 环境变量在无管理员时自动创建首个本地管理员
- 前端密码输入改用 type=password,API Key 占位符改为动态前缀
- 删除过时的 pyproject.toml/uv.lock 和旧部署文档
- 更新 .env.example/README 反映新配置项
2026-04-11 17:39:02 +08:00
fawney19 a570a77cca fix(data): baseline 迁移末尾恢复 search_path 为 public,确保 sqlx 记账正常 2026-04-11 16:17:02 +08:00
fawney19 b0f068cc5d ci(rust): 移除冗余的 build_release job,测试已覆盖编译 2026-04-11 15:33:04 +08:00
fawney19 fff82de933 fix(data): baseline 迁移 search_path 改为事务级作用域,修复 sqlx 迁移表访问问题 2026-04-11 15:29:13 +08:00
fawney19 8a4a41fcef fix(docker): 容器以 root 运行,解决日志目录写入权限问题 2026-04-11 15:07:44 +08:00
fawney19 aa5761954e refactor(data): baseline 迁移改为完整幂等建库脚本,放宽 checksum 校验
- baseline migration 从占位 SELECT 1 替换为完整的幂等 DDL,支持全新数据库从零建库
- 迁移校验从 checksum 严格报错改为 warn-only,仅按版本匹配
- 移除过时的 schema 导出文件(README/TSV)
- Dockerfile 移除 :nonroot 标签,docker-compose 移除 user 指令,统一以默认用户运行
2026-04-11 14:31:28 +08:00
fawney19 8cd2c4d5bd fix(gateway): 默认监听端口改为 8084 以支持非 root 运行
- Dockerfile 及 main.rs 默认 bind 从 0.0.0.0:80 改为 0.0.0.0:8084
- docker-compose 以 UID:GID 非 root 用户启动 app 容器
- compose 端口映射与容器内监听端口保持一致,通过 AETHER_GATEWAY_BIND 注入
2026-04-11 13:55:11 +08:00
fawney19 940a28cff4 chore: 清理 .env.example 中过时的 Python 运行时配置项 2026-04-11 13:12:28 +08:00
fawney19 c95feea286 chore: 清理过时的 Python 遗留配置和文档
- .dockerignore 移除 Python 相关忽略规则和废弃的 dist 放行条目
- README 更新密钥生成为 shell 脚本,移除过时的 systemd 部署章节
- deploy.sh 从哈希计算中移除已删除的 entrypoint.sh
2026-04-11 13:00:04 +08:00
fawney19 1d896467dc fix(build): .dockerignore 放行 dist/aether-gateway-* 和 dist/frontend/ 2026-04-11 12:49:40 +08:00
fawney19 3a655440b9 chore: 移除过时 Python 脚本,密钥生成改用 shell 脚本
- generate_keys.py 替换为 generate_keys.sh (无需 Python 依赖)
- 移除已废弃的 backfill_provider_key_status_snapshot.py
2026-04-11 12:43:19 +08:00
fawney19 80bae8bc2a fix(build): ldap3 切换到 rustls 后端以消除 openssl-sys 依赖
musl 交叉编译时 openssl-sys 找不到系统 OpenSSL, 改用 tls-rustls feature
2026-04-11 12:36:35 +08:00
fawney19 f68c67021c refactor(build): 切换到 musl 交叉编译 + distroless 镜像方案
- docker-publish.yml: 改为 cross 交叉编译 amd64/arm64 musl 静态二进制,
  前端在 CI 独立构建, buildx 组装多架构镜像
- Dockerfile.app: 从 139 行容器内编译简化为 24 行纯 COPY 打包
- Dockerfile.app.local: 移除 jemalloc 动态链接 (LD_PRELOAD/libjemalloc2)
- aether-gateway: 引入 tikv-jemallocator 静态链接 jemalloc
2026-04-11 12:21:33 +08:00
fawney19 e37a32c83d chore(deploy): 默认镜像标签切换为 pre 以跟踪预发布版本 2026-04-11 10:48:34 +08:00
fawney19 ec0bde819a refactor: 简化 outcome clippy lint 并移动 usage 测试模块至文件末尾 2026-04-11 09:35:31 +08:00
fawney19 848f99d3e5 merge(pr287): absorb remaining rust branch changes
Absorb the remaining changes from PR #287 into aether-rust-pioneer after resolving conflicts locally and preserving the admin fixes already landed in this branch.

Closes #287
Co-authored-by: AAEE86 <[email protected]>
2026-04-11 03:10:16 +08:00
fawney19 34d295c1e0 fix(admin): align global model responses with repository counts 2026-04-11 02:55:02 +08:00
fawney19 a54ac76688 fix(admin): restore pool key usage aggregates 2026-04-11 02:54:54 +08:00
fawney19 cf02a10050 chore: 调整 Postgres 连接池默认参数:增大最大连接数、缩短获取超时 2026-04-11 02:26:33 +08:00
fawney19 6144473ebe feat: 新增 frontdoor 执行回环守卫与多项可观测性增强
- 新增 frontdoor_loop_guard 模块,检测并拒绝 execution runtime 回环到本地网关的请求(HTTP 508)
- candidate loop 引入 span tracking、执行尝试日志与流式看门狗超时
- 本地故障转移策略支持从 report_context 加载,新增 append_local_failover_policy_to_value
- runtime tracing 美化:移除 identity 前缀,按 span 深度树形缩进,target 固定宽度展示
- Codex OpenAI CLI 补齐 chatgpt-account-id/x-client-request-id/session_id/conversation_id 请求头
- OpenAI CLI same/cross-format 聚合规则放宽以支持 openai:compact 客户端格式,并过滤 error-like 响应体
- auth/proxy/finalize 日志补充 user_id/api_key_id/api_key_name/balance_remaining 等字段
- 启动日志拆分为 starting/ready/config 三段,新增 resolve_bind_http_base_url
- access_log middleware 将生成的 trace_id 回注到下游请求头
- Cargo.toml 启用 serde_json preserve_order 特性
2026-04-11 01:50:24 +08:00
AAEE86 174f11604a fix(data): 统一 usage 内存仓储时间戳单位并通过格式检查
- 将 usage 内存仓储的秒级过滤条件转换为毫秒后再比较
- 汇总 provider api key 最近使用时间时将毫秒转换为秒
- upsert 在缺少 created_at 时默认写入毫秒时间戳
- 补充时间戳单位回归测试
- 调整 registry.rs 格式以通过 cargo fmt --all --check
2026-04-10 22:23:26 +08:00
fawney19 3f057628b7 fix: 修正用量失败状态判断与 Codex 会话头 2026-04-10 21:33:34 +08:00
fawney19 46f1507d44 feat: 提前记录 pending 用量、优化流遥测时序与前端活跃请求发现机制
- 将 record_pending 调用移至执行开始前(sync/stream 两路),确保请求在执行前即有 pending 记录
- stream_pump 在收到第一个数据块前优先 yield 遥测帧,保证 ttfb 早于 data 帧到达
- stream execution 增加 should_refresh_stream_usage_telemetry,在遥测帧携带新 ttfb/elapsed 时及时更新 record_stream_started
- access_log 对高频轮询路径(usage/active、usage/records 等)降级为 TRACE 日志,减少日志噪音
- 前端新增 reconcileActiveRequestDiscovery 工具函数及 discoverActiveRequests 逻辑,活跃请求发现与全局自动刷新解耦,空闲时降频为 5 秒扫描
- RequestDetailDrawer 调整:进行中请求不再自动开启轮询,由用户手动触发;刷新按钮 title 动态适配状态
2026-04-10 20:58:01 +08:00
fawney19 d5b8583d6b fix: 修正时间戳精度、TTL 定价匹配逻辑及账单快照展示
- 将 created_at_unix_ms 从 current_unix_secs 改为 current_unix_ms,修正候选尝试和跳过记录的时间戳精度
- formula_engine: TTL 定价从「<=上限」模糊匹配改为精确匹配,null 值改为回退到基础价格而非透传
- 新增 ttl_pricing_requires_exact_match 和 ttl_pricing_null_value_falls_back_to_base_tier_value 测试用例
- service: 新增 5min/1h cache TTL 的端到端计费验证测试
- RequestDetailDrawer: 优先从 billing_snapshot 读取已解析的价格和费用,正确展示当前 TTL 对应的缓存创建/读取价格,修正输入/输出/缓存费用列的数据来源
2026-04-10 18:50:59 +08:00
AAEE86 b1f6fff0a5 fix(gateway): 修复 local finalize 与 usage 聚合相关测试失败
- 修正内存 usage 仓库的时间单位处理,恢复 stats/monitoring/dashboard/wallet/pool 聚合结果
- 允许 openai:compact 走 CLI 响应转换与本地 finalize cross-format 路径
- 更新 cross-format 测试夹具,显式启用 enable_format_conversion
- 调整 failover 测试优先级,消除候选平局导致的顺序不稳定
- 补齐 ai_pipeline 架构断言所需导入并清理残留 runtime 空目录
2026-04-10 18:27:02 +08:00
fawney19 010ab127e2 feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑
- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token
- 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口
- billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费
- usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性
- 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出
- executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义
- provider-transport auth 层新增 complete passthrough headers 构建逻辑
- 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
2026-04-10 17:44:55 +08:00
AAEE86 82c3c33610 fix(build): 修复网关构建失败并收口候选选择与 finalize 回归
- 补齐 DecisionTraceCandidate 新增字段,修复审计测试构造
- 修正 usage 内存仓库的 created_at_unix_ms 字段引用与秒/毫秒换算
- 将 build_minimal_candidate_selection 重构为输入对象,消除 clippy 参数过多问题
- 修复 admin global model created_at 旧字段残留引用
- 修复 openai:cli 与 openai:compact 同家族 finalize 在 needs_conversion=true 时的成功回落逻辑
- 清理 aether-gateway 中的 derive/default 与 needless borrow 等 clippy 问题
2026-04-10 17:23:40 +08:00
AAEE86 677b8f5acb Merge remote-tracking branch 'upstream/aether-rust-pioneer' into rust 2026-04-10 15:08:32 +08:00
AAEE86 b8ce02b4f7 refactor(core): 重构 Provider Ops 架构注册、校验链路与余额缓存流程
- 将 provider ops 纯逻辑下沉到 aether-admin,拆分 architectures、actions、verify 模块
- 用统一的 architecture spec 驱动 verify、query_balance、checkin 行为,替换分散的条件分支
- 新增 sub2api / anyrouter / cubence / yescode 等架构的请求头构建、校验解析与余额解析实现
- 引入 provider balance Redis 缓存、异步刷新、pending 响应以及配置变更后的缓存清理
- 补充 provider ops 的控制面测试、Redis 缓存测试和架构边界测试

- 影响说明:统一了 Provider Ops 的扩展方式与运行时行为,降低后续新增架构的接入成本
- 影响说明:余额查询从“实时阻塞返回”扩展为“缓存命中即返回并后台刷新”的模式,前端需要兼容 pending 状态
2026-04-10 15:06:42 +08:00
fawney19 5014e2f5fd refactor: 抽离 AI pipeline 与调度共享能力逻辑 2026-04-10 01:46:14 +08:00
AAEE86 87b433e290 fix(gateway): 管理端 provider query helper 改用 AdminAppState
将 admin provider query 路由相邻层 helper 中直接使用的 AppState 参数改为 AdminAppState,并通过 state.app() 访问底层状态,满足 admin_shared 架构约束。
2026-04-09 16:23:47 +08:00
AAEE86 5581f7a085 fix(gateway): 对齐 admin global models Rust 响应与 Python 字段口径
- 为 /api/admin/models/global 补齐 usage_count 字段
- 直接使用仓储层 provider_count 和 active_provider_count 统计结果
- 修复 handler 层重复统计导致的 active_provider_count 口径偏差
- 移除列表接口额外的 provider models 查询
- 补充列表、详情和 payload 组装的回归测试
2026-04-09 15:57:32 +08:00
AAEE86 0b3f619280 fix: 号池列表统计与最后使用时间显示
- 为 usage 仓储新增按 provider_api_key_id 汇总请求数、Token、费用和最后使用时间的能力
- 在 /api/admin/pool/{provider_id}/keys 中优先使用 usage 汇总结果覆盖 request_count、total_tokens、total_cost_usd、last_used_at
- 补充数据层与网关侧回归测试,避免号池管理页统计全为 0 且最后使用为空
2026-04-09 15:10:21 +08:00
fawney19 b901a6ffc7 chore: 移除废弃的 build-hub workflow,格式化测试代码 2026-04-09 14:01:37 +08:00
fawney19 fe81eafe2c feat: 增强 provider query 端点选择逻辑,支持 openai:responses 格式与 key 级 api_formats 过滤
- provider_query_selected_fetch_endpoints 支持按 key.api_formats 过滤可用端点
- 端点选择增加 endpoint_supports_rust_models_fetch 检查
- 支持优先级列表之外的自定义 api_format 端点
- 新增 openai:responses 端点的 provider query 集成测试
- 各 crate 补充缺失的 tracing instrument 和小修正
- 前端 Pool 管理页面补充状态逻辑
2026-04-09 13:55:18 +08:00
fawney19 b0b40c16ff feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面
后端:
- 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理
- 完善 scheduler-core 候选排序与请求候选逻辑
- 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新
- 改进 AI pipeline 响应转换与流式处理
- 扩展 global_models/provider_catalog 数据层查询能力
- 增强 video-tasks-core 多 provider 支持
- 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor

前端:
- 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块
- 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试
- 改进 Dialog 组件与 provider tabs 显示

部署:
- 更新 Rust CI workflow 和 Dockerfile 构建配置

Closes #275
Co-authored-by: AAEE86 <[email protected]>
2026-04-09 13:51:50 +08:00
fawney19 4fc95adfb9 refactor: 大规模模块拆分与重组,新增 aether-admin crate
- 新建独立 aether-admin crate 承载 admin 相关共享契约与纯辅助函数
- 拆分 ai_pipeline 下 kiro/private_envelope/conversion/planner 等大文件为子模块目录
- 重组 admin handlers 各业务域(billing/oauth/provider/system/users 等)为目录结构,移除 shared.rs/builders.rs 等反模式
- 移除 ai_pipeline runtime adapters 旧实现(claude/openai/gemini/kiro/vertex/antigravity 等),改由 provider transport 统一承载
- 移除 control_facade/execution_facade/auth_snapshot_facade 等冗余 facade 层
- 拆分 query/billing 与 query/monitoring 模块、state/runtime/payments 与 security 模块
- 扩展架构测试覆盖 admin_billing/admin_model/admin_users 等新模块
- 删除 docs/architecture/refactor-execution-plan.md 已完成的执行计划文档
2026-04-09 00:10:38 +08:00
fawney19 4fb9882b54 refactor: 拆分 admin handler 大文件为模块目录,消除 shared.rs 反模式
- endpoint_keys/pool/architecture 等大文件拆分为独立模块目录
- 删除 crud/query/strategy/system 中的 shared.rs,内容归位到各自模块
- endpoints_admin/models/oauth/write 等模块拆出 payloads/responses/support 子文件
- system/core 下多个 *_routes.rs 合并到 system_routes.rs
- 更新 refactor-execution-plan 文档进度
2026-04-07 12:15:19 +08:00
fawney19 29055c575f refactor: 拆分 system.rs 大文件,将 email_templates/proxy_errors/system_config 下沉到 shared 层
- 删除 1666 行的 admin/system/shared/system.rs,按职责拆分到独立模块
- 新增 handlers/shared/email_templates.rs 和 system_config_values.rs 存放跨层共用的模板/配置工具函数
- 新增 admin/system/shared/email_templates.rs 存放 admin 专用的模板操作逻辑
- 新增 admin/shared/proxy_errors.rs 存放 build_proxy_error_response
- 清理 public/system_modules_helpers 中不属于 public 层的导出
- 新增架构测试守护模块归属边界
2026-04-07 08:19:26 +08:00
fawney19 5d96d6673b refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦
- 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块
- 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支
- 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合
- 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor
- 前端适配:调整 admin API 调用和 provider 模型测试对话框
2026-04-07 02:50:19 +08:00
fawney19 763ff03a7b refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案
将 gateway 内部的 model-fetch、provider-transport、scheduler-core、
usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway
内部模块结构(state/router/cache/data/query 等);移除大量遗留模块
文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关
API 和组件。
2026-04-05 20:23:16 +08:00
fawney19 cbc811f6ce refactor: 移除 Python upstream 依赖,清理全部 legacy/Python 兼容层
- 移除 upstream_base_url 参数及 AETHER_GATEWAY_UPSTREAM 环境变量,gateway 不再需要指向 Python 宿主
- 删除所有 LEGACY_*/PYTHON_* 常量、路由组、header 定义及 sunset/phaseout 机制
- 将 legacy_gateway_bridge 重命名为 internal_gateway,executor 相关命名统一为 execution_runtime
- dev.sh 新增 Postgres/Redis 预检查,移除 upstream 相关启动参数和提示
- 新增 ai_public 路由处理器
- 全量适配 handler、test、state、control 等模块的命名和接口变更
2026-04-04 01:40:24 +08:00
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00
fawney19 8f26e1a31f refactor: 移除独立 hub/proxy/executor/gateway crate,统一为 gateway tunnel 架构
- 删除 aether-hub、aether-proxy 独立项目及其 Dockerfile/配置
- 删除 crates/aether-executor 和 crates/aether-gateway 全部模块
- 新增 apps/ 目录作为应用入口
- 将 hub 概念重构为 gateway tunnel transport
- 将 executor 重构为 execution runtime
- 新增 tunnel.rs 合约定义和 testkit tunnel/execution_runtime 模块
- 更新 Python 服务层和测试适配新架构命名
2026-04-03 14:59:58 +08:00
fawney19 ddf18fed9a feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate
- aether-data 扩展 repository 层:announcements、auth_modules、billing、
  candidate_selection、gemini_file_mappings、global_models、management_tokens、
  oauth_providers、proxy_nodes、quota、users、wallet 等模块
- aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/
  video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块
- 重构 executor decision 和 gateway state 为模块目录结构
- 新增 gateway router、frontdoor 路由层及对应测试
- Python 侧 API 路由重构,新增 compat/support 模块
- 前端 Logo 组件更新及 Provider 管理页面调整
2026-03-31 19:19:04 +08:00
fawney19 b5a0070023 feat: 引入 aether-runtime/cache/data/http/testkit 基础 crate,完善并发门控与审计系统
新增 crate:
- aether-runtime: 服务运行时基础设施(并发门控、分布式并发、指标、队列、优雅关闭、tracing)
- aether-cache: 通用 TTL 缓存与命名空间抽象
- aether-data: 数据访问层(PostgreSQL/Redis 后端、repository 模式)
- aether-http: HTTP 客户端封装(重试、配置)
- aether-testkit: 集成测试工具集(gateway/executor/hub/proxy fixture、等待、负载测试)

gateway 扩展:
- 引入 audit 模块(shadow 执行审计、决策链路追踪、请求审计 bundle)
- 引入 cache 模块(AuthContext 缓存、direct-plan bypass 缓存)
- 引入 data 模块(auth/candidates/config/usage/video_tasks 数据访问)
- 集成 ConcurrencyGate/DistributedConcurrencyGate 请求门控
- 新增本地 auth 拒绝、过载响应构建器
- 补充 control/auth_cache/video/concurrency 集成测试

aether-proxy 扩展:
- AppState 集成 stream_gate / distributed_stream_gate 并发门控
- 新增 ProxyAdmissionError 及准入拒绝流程
- stream_handler 补充门控饱和/不可用场景测试
- 配置与注册客户端逻辑完善

aether-hub 扩展:
- main.rs 引入运行时初始化、指标端点、健康检查
- local_relay 重构为 lib.rs 暴露公共接口
2026-03-24 15:12:56 +08:00
fawney19 eaf8475f9e refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块

Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整

测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
fawney19 455234e797 feat: 实现 executor 流式执行路径及完善 gateway 多格式 plan 构建
- Rust executor 新增流式 plan 支持,覆盖 openai/claude/gemini 的 chat/cli/video 格式
- Gateway 新增 finalize-sync、report-stream 端点及 stream report 模型
- 新增 video sync 操作(create/cancel/remix/delete)的 plan 构建
- 修正 OpenAI Responses(openai:cli) SSE 格式: 添加 event: 行、移除 [DONE] 哨兵
- 统一 OpenAI CLI normalizer 的 ID 生成方法
2026-03-21 15:51:37 +08:00
fawney19 53bb23b510 feat: 实现 executor sync 执行路径及 plan-sync/report-sync 端点
Rust gateway 新增同步执行模式,支持 AI 请求(OpenAI/Claude/Gemini chat/cli)
及 Gemini Files CRUD 通过 executor 直接同步执行,执行完成后通过 report-sync
端点向 control 回报结果。Python 侧新增 /plan-sync 和 /report-sync 内部端点,
构建同步执行计划并处理结果上报。包含完整测试覆盖。
2026-03-21 14:07:45 +08:00
fawney19 d735b6316f feat: 引入 Rust executor/gateway sidecar 及 Python 侧双后端适配
- 新增 Rust workspace crates: aether-contracts, aether-executor, aether-gateway
- aether-executor: 支持 Unix Socket/TCP 双传输模式,处理同步/流式上游请求
- aether-gateway: 作为本地主入口代理,集成 /api/internal/gateway/resolve 认证预解析
- Python 侧新增 ExecutionPlan 契约和 RustExecutorClient,各 handler 支持
  executor_backend=rust 时将可序列化请求转发给 Rust executor 执行
- 重构 dev.sh 支持 executor/gateway 进程编排与生命周期管理
- 新增 internal gateway 路由,提供 resolve/passthrough 端点
- handler 层(chat/cli/video/endpoint_checker 等)全面适配 Rust executor 回退逻辑
- pipeline 层支持 trusted auth context 跳过重复认证
- 新增 Rust CI workflow 及对应测试用例
2026-03-21 12:57:09 +08:00
fawney19 46737d32f8 feat: 引入 status_snapshot 统一 provider key 状态管理
- 新增 StatusSnapshot 模型,聚合 OAuth / 账号 / 配额三维状态
- 新增 StatusSnapshotStore 负责快照的持久化与查询
- 重构 response_builder / endpoint_models,基于 snapshot 输出状态字段
- 前端抽取 providerKeyStatus / oauthRefreshFeedback 工具函数,
  统一 PoolManagement、ProviderDetailDrawer、BatchDialog 的状态展示
- errorParser 增加已知 OAuth 错误的友好提示
- refresher 适配 snapshot 写入,account_state 扩展状态分类
- 新增 alembic 迁移及存量数据回填脚本
- 补充前后端单元测试
2026-03-20 19:16:52 +08:00
fawney19 25d38ae632 feat(oauth): 账号封禁前置 OAuth 验证、抽取 provider_context、完善账号状态分类
- 新增 verify_oauth_before_account_block:在标记账号封禁前先尝试刷新 token,
  区分 OAuth 过期与真正的账号级封禁,避免误标
- 抽取 provider_context.py 统一解析 provider_type,解决 ORM detached 访问问题
- account_state 新增 workspace_deactivated 分类和 auto-removable 状态集合,
  补充中文验证关键词匹配
- OAuth refresh 成功后仅清除可恢复的 token 错误,不再自动清除账号级 block
- deploy.sh 依赖指纹改用纯 shell 实现,移除对 Python tomllib 的依赖
- 前端 Pool 管理页面新增筛选和批量操作优化
- 补充对应测试用例
2026-03-20 16:50:59 +08:00
fawney19 aa83b4a7a7 fix: 加固续租失败处理、verify_auth 异常捕获及调度器注册追踪
- task_coordinator: 续租连续失败 5 次后主动触发 lock_lost 回调,失败间加指数退避
- proxy_nodes: lock_lost 回调由 lambda 改为具名 async 函数,确保异步停止逻辑正确执行
- provider_ops: 将 prepare_verify_config 纳入外层 try,捕获 ValueError 并返回失败响应
- maintenance_scheduler: 用 _registered_job_ids 动态追踪已注册任务,stop 时按列表清理
- stats_aggregator: 内联 _do_aggregate 为 for/range(2) 循环,消除内嵌函数
2026-03-20 01:22:07 +08:00
fawney19 913ce2dbcb Merge pull request #252 from AAEE86/nn
fix(startup): 收口 leader 失锁后的后台任务
2026-03-20 01:13:01 +08:00
fawney19 cae5e520ac fix(frontend): 修复 restoreOriginalPlaceholder 递归调用、优化日志参数格式,补全 tsconfig lib 配置 2026-03-20 01:06:15 +08:00
fawney19 772f2ea601 fix(vertex): SA 认证注入代理配置,细化 token 获取异常处理
- _auth_service_account 接收 endpoint 参数,通过 _get_proxy_config 解析代理
- vertex_auth 区分 TimeoutException/RequestError/通用异常,提供可读错误信息
- 新增测试覆盖代理传递和超时场景
2026-03-20 00:49:44 +08:00
fawney19 28fa03451c feat(provider): 模型测试支持自定义请求头,优化对话框布局与并发策略
- 前后端新增 request_headers 字段,测试时可自定义额外请求头
- ModelTestDialog 拆分为请求头/请求体并排双栏布局,增加格式化与重置按钮
- 区分 Pool 托管(并发5)和单 Key Provider(并发1)的测试并发数
- JsonImportInput 新增 multiple prop 支持单文件模式
- KeyFormDialog Service Account 输入改用 JsonImportInput,支持拖拽导入
2026-03-20 00:24:56 +08:00
fawney19 6984984c22 feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层
模型测试:
- 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验
- 新增端点选择面板,测试前可选择目标端点
- 新增调试检查器,可查看每次尝试的请求/响应头和体
- 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪
- endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化

Vertex AI:
- 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断
- Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key)
- 移除 Gemini Developer API 回退,API Key 仅走 Express 模式
- 端点表单为 Vertex AI 显示格式特定的默认路径模板
- 密钥格式校验仅在 auth_type/api_formats 变更时执行

其他:
- 禁用 ClaudeCode 提供商类型创建入口
- Dialog 组件新增 closeOnBackdrop 属性
2026-03-19 23:52:17 +08:00
AAEE86 1209c835c7 fix(startup): 收口 leader 失锁后的后台任务
- 为后台调度器注册失锁回调并只在 stop 成功后清空生命周期引用
- 停止调度器时移除定时 job,补充启动与任务协调器回归测试
- 降低多 worker 下重复调度风险,保持停机收口与统计聚合回归一致
2026-03-19 22:26:51 +08:00
fawney19 e4ebd5cca1 refactor(oauth): LinuxDo 备用端点回退、Basic Auth 认证,修复 session 外访问 ORM 对象
- LinuxDo provider: token/userinfo 请求增加 backup 端点自动回退
- LinuxDo provider: token 请求改用 HTTP Basic Auth 认证
- 授权 URL 构建: scope 为空时不再发送该参数
- OAuthService: 引入 OAuthAuthenticatedUser 快照,避免 DB session 关闭后访问 ORM 对象
- OAuthService: _handle_login_sync 设置 expire_on_commit=False 防止属性过期
- 新增 LinuxDo provider 单元测试(Basic Auth、端点回退)
- 新增 _handle_login_sync 返回快照的集成测试
2026-03-19 20:32:33 +08:00
fawney19 f573110725 fix(frontend): 用 CSS text-security 替代 password 输入框,简化配额进度条 UI
- Input 组件 masked 模式改用 WebkitTextSecurity: disc 替代 type=password,
  避免浏览器密码管理器自动填充干扰
- LoginDialog/UserFormDialog/Settings/ProxyNodes 密码字段统一迁移到 masked 属性
- PoolManagement 配额进度条布局从 grid 改为 flex,移除未使用的
  getQuotaProgressDisplayClass/getQuotaProgressTooltip 函数

Close #249
Co-authored-by: AAEE86 <[email protected]>
2026-03-19 20:04:56 +08:00
fawney19andAAEE86 a8620e133a fix(kiro): 加固 Kiro adapter 错误处理与请求构建逻辑
- 提取 request.py 统一 URL/headers/payload 构建,消除 handler_adapter_base 与 envelope 的重复逻辑
- 新增 error_enhancer 模块,分类 HTTP 状态码与连接错误,增强上游错误诊断信息
- envelope 实现 extract_error_text / on_http_status / on_connection_error,透传错误上下文到 eventstream rewriter
- KiroRequestContext 扩展错误状态字段,支持网络诊断信息传递
- provider_oauth_utils 改用 importlib 动态加载,避免 core 层对 services 的静态依赖
- idc auth_method 下跳过 profileArn,修复 usage 查询参数

Closes #247

Co-authored-by: AAEE86 <[email protected]>
2026-03-19 13:54:40 +08:00
RWDai ddd6adbcf7 feat(provider): support custom prompts for model tests (#242) 2026-03-19 13:11:54 +08:00
fawney19 b570aaac48 fix: 补全 OpenAI 工具参数 schema 中缺失的 properties 字段
OpenAI API 要求 type=object 的 schema 节点必须声明 properties,
否则会拒绝请求。在 request_from_internal 出口处对工具参数 schema
进行深拷贝并递归补全缺失的空 properties,不影响内部表示。
2026-03-19 02:36:22 +08:00
fawney19 086efe6efe refactor(usage-queue): ACK 后立即删除消息,清理 consumer 元数据
- 消费成功/移入 DLQ 后立即 XDEL,避免主流 Redis 保留已入库历史
- 缩小 usage_queue_stream_maxlen 默认值:200000 -> 2000(仅作短暂缓冲)
- 启动时清理 pending=0 且长期闲置的旧 consumer(防 consumer group 元数据累积)
- 停机时主动 XGROUP DELCONSUMER 移除自身
- 移除 cache_fingerprint 模块及其对 telemetry/recording_helpers 的引用
- 同步更新相关测试,验证 xdel 调用及 consumer 生命周期行为
2026-03-19 02:09:09 +08:00
fawney19 56f3c95763 fix: 统一 input_context_expr 计算口径,移除按 api_format 分支的 CASE 逻辑
input_context_expr() 原先按 OpenAI/Gemini 和 Claude 分支计算输入上下文,
现统一为 input_tokens + cache_read_input_tokens,与 usage 表展示口径一致。
同步更新 admin 和 user_me 路由中的缓存命中率注释,并新增单元测试。
2026-03-19 01:35:00 +08:00
fawney19 8a6a961900 feat: 增强 OAuth 标识展示与 Codex 调试日志
- 优化 OAuth badge 支持 account_id/account_user_id,tooltip 展示完整身份信息
- 重构池管理额度进度条布局,倒计时独立展示并增加样式区分
- Codex 插件增加 token 解析快照日志,便于排查 OAuth 透传问题

Closes #245
Co-Authored-By: kayphoon <[email protected]>
2026-03-19 00:50:18 +08:00
kayphoon 6e55968487 feat: Codex account_name 透传并优化池列表 OAuth 标识与额度重置倒计时展示
(cherry picked from commit 1b9176fc4a)
2026-03-18 23:58:42 +08:00
fawney19 8d8cddcef6 fix(replay): rerun model mapping on replay target 2026-03-18 23:54:19 +08:00
RWDai b90d5095f1 Fix replay fallback when model mapping is missing 2026-03-18 23:54:19 +08:00
RWDai a4505b1281 Fix usage replay model remapping 2026-03-18 23:54:19 +08:00
fawney19 1d72a8f9c1 feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复
Close #233

Co-authored-by: AAEE86 <[email protected]>

- cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置),
  流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起
- stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在
  telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建
- health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format
  单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃
  provider/endpoint,与公开健康接口保持一致
- endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合
- token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长;
  修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断
- maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than
  边界参数,避免同一行在同一轮中被重复改写
- sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建
- quota_reader / provider_ops balance: 小幅修复与健壮性提升
- Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS
- 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
fawney19 3d5b6141a5 feat(codex): 引入 upstream_headers hook 机制,为 Codex 注入 session/conversation/account headers
- 新增 upstream_headers.py:可注册 provider+endpoint 维度的 extra headers 构建 hook
- Codex openai:cli 注入 session_id + conversation_id(由 prompt_cache_key sha256 派生)
- Codex openai:compact 注入 chatgpt-account-id(来自 auth_config)+ session_id,不注入 conversation_id
- 修复 prompt_cache:compact 格式现统一为 codex 策略,不再跳过注入
- chat_handler_base / cli_request_mixin 均在 extra_headers 阶段调用 build_upstream_extra_headers
2026-03-18 20:43:14 +08:00
fawney19 203cd5a9d5 fix(redis): 按事件循环隔离 Redis 连接,防止子线程 asyncio.run 导致连接泄漏
RedisClientManager 新增 _redis_by_loop 字典,按 event loop id 维护独立连接,
避免 usage consumer 在 asyncio.to_thread + asyncio.run 场景下复用主循环连接。

同步重构 consumer_streams 的写库路径:_apply_record_event 统一走
record_usage_batch,_apply_streaming_event 改为 to_thread 执行同步 DB 操作,
移除冗余的 session 传递和手动 rollback 逻辑。
2026-03-18 17:56:48 +08:00
fawney19 696ec65175 refactor(normalizer): 移除 request key reorder 机制,保持自然插入顺序
移除 OpenAI/OpenAI CLI normalizer 中的 _reorder_request_prefix_keys 及
基类 _reorder_request_keys 死代码,request_from_internal 直接返回构建
顺序的 dict,测试同步更新为验证自然插入顺序。
2026-03-18 14:01:56 +08:00
fawney19 cbb66a5667 refactor(task): 引入 MutableRequestBodyState 替代 request_body_ref 字典容器
将请求体可变状态从 {"body": dict} 字典容器重构为独立的
MutableRequestBodyState 类,统一管理 original_body / current_body /
build_attempt_body / rectify 等语义,消除各层通过 ref["body"] 间接
读写的隐式约定。

- 新增 src/services/task/request_state.py 定义 Protocol 与实现
- handler/executor/mixin 层改用 request_state 参数传递
- error_handler/state_transition 通过 request_state 判断整流状态
- 新增 request_state 单元测试与 chat/cli 请求体隔离测试
2026-03-18 13:43:39 +08:00
fawney19 53ef35ec80 refactor(cli): 提取 _build_upstream_request 统一流式/非流式的上游请求构建逻辑
将 cli_stream_mixin 和 cli_sync_mixin 中重复的上游请求构建代码
(provider behavior / stream policy / envelope / auth / RequestBuilder / URL 构建)
提取到 cli_request_mixin._build_upstream_request,返回 CliUpstreamRequestResult dataclass。
2026-03-18 13:13:47 +08:00
fawney19 1af3067303 refactor(codex): 移除 envelope/request_patching 层,用 context var 统一 compact 状态判断
- 删除 CodexOAuthEnvelope 和 request_patching 模块,Codex 不再需要 envelope 层
- 移除 _aether_compact 请求体内部标记,改用 is_codex_compact_request() 集中查询
- 简化 OpenAI CLI adapter,移除 Codex 专用的 get_cli_extra_headers/build_test_request_body 逻辑
- 移除 Codex behavior variant 注册(same_format/cross_format)
- normalizer patch_same_format_request 对 codex 变为 no-op
- 更新相关测试适配新的架构
2026-03-18 12:35:57 +08:00
fawney19 d026398bab refactor(body-rules): 移除 protected_body_keys 机制,允许 body_rules 自由修改所有请求体字段
删除 get_cache_sensitive_protected_body_keys 函数及相关常量、_is_protected_path
辅助函数,从 apply_body_rules、RequestBuilder、ProviderRequestResult 等处移除
protected_body_keys 参数,更新所有调用点和测试用例。
2026-03-18 10:52:07 +08:00
fawney19 684689a82b fix(usage): session touch 独立提交避免行锁阻塞 & 管理员页面顺序加载降低并发压力
后端: 将 session touch 的 commit 从请求事务中分离,防止管理员 usage
页面的长查询持有 user_sessions 行锁阻塞后续请求。touch_session 改为
返回 bool 以支持按需提交。

前端: 管理员 Usage 页面将并行 API 调用改为顺序加载,优先显示记录表格,
统计面板在后台异步刷新,避免瞬时并发打满后端 worker。loadRecords 支持
传入 dateRange 参数确保时间范围一致性。
2026-03-18 00:13:28 +08:00
github-actions[bot] eeb5f41bad chore(proxy): update download links for proxy-v0.2.5 2026-03-17 14:23:27 +00:00
fawney19 7180eaea88 chore: bump aether-proxy version to 0.2.5 2026-03-17 22:16:47 +08:00
fawney19 7cb204f18a chore: bump aether-hub version to 0.2.0 2026-03-17 22:15:07 +08:00
fawney19 0342f609d0 feat(tunnel): 请求体流式传输 & OpenAI CLI 请求 key 排序优化
Hub 端:
- open_local_stream 不再接收 body 参数,改为通过 push_local_request_body 分块推送
- 请求体按 32KB 分帧发送,避免大请求一次性压缩和传输
- local_relay 改为流式解析 envelope 和转发请求体

Proxy 端:
- stream_handler 改为流式传输请求体到上游,不再预先收集完整 body
- upstream_client 请求体类型从 Full<Bytes> 改为 UnsyncBoxBody 以支持流式传输
- dispatcher 将 StreamEnd/StreamError 事件转发给 stream handler

Python 端:
- hub_transport relay envelope 改为异步生成器流式发送
- 提取 reorder_openai_cli_request_prefix_keys 为公共函数
- Codex passthrough 路径也应用稳定的前缀 key 排序
2026-03-17 22:07:09 +08:00
fawney19 59840fa419 feat(docker): 支持通过 GITHUB_MIRROR 参数加速 hub 二进制下载
Dockerfile.app.local 新增 GITHUB_MIRROR 构建参数,deploy.sh
新增 --mirror 选项,国内服务器可指定镜像代理地址加速下载。
2026-03-17 20:43:14 +08:00
fawney19 d390d46ee8 feat(docker): Dockerfile.app.local 支持本地 hub 二进制文件
将 aether-hub tar.gz 放到项目根目录即可跳过 GitHub 下载,
解决国内服务器构建时 GitHub 访问慢的问题。
2026-03-17 20:37:09 +08:00
fawney19 37eada9682 chore: bump aether-hub version to 0.1.9 2026-03-17 20:26:29 +08:00
fawney19 73a5325a38 refactor(hub): 用本地 HTTP relay 替代 Worker WebSocket 长连接
Hub 数据面改为 /local/relay/{node_id} HTTP 端点,Worker 通过本机
HTTP 请求转发 tunnel 帧,不再维护 /worker WebSocket 长连接。

Hub 侧:
- 新增 control_plane.rs: Hub 通过 HTTP 回调 Aether app 处理心跳 ACK 和节点状态变更
- 新增 local_relay.rs: 接收本地 HTTP 请求,在 Hub 内部打开 LocalStream 并透传到 proxy
- 移除 worker_conn.rs 及 Worker WebSocket 处理逻辑
- 简化 protocol.rs: 移除 NODE_STATUS 帧类型,抽取通用 encode_frame/decode_payload

Python 侧:
- 删除 tunnel_manager.py 及其 WebSocket 连接管理器 (HubConnectionManager)
- 简化 hub_transport.py 为 HTTP relay 调用
- 新增 src/api/internal/hub.py 接收 Hub 控制面回调 (heartbeat/node-status)
- hub_config.py 移除 WebSocket 相关配置,改为 HTTP relay URL
- service.py 新增 update_tunnel_status 方法
- 删除 src/api/admin/proxy_tunnel.py (旧管理接口)
- proxy_node 缓存 TTL 从 15s 降至 3s 加速状态感知
2026-03-17 20:22:12 +08:00
fawney19 460eb5434d fix(proxy-resolver): 将 resolve_ops_proxy_config 改为异步调用避免阻塞事件循环
在 anyrouter/nekocode/sub2api/yescode 架构及 service.py 中,
将同步的 resolve_ops_proxy_config 替换为 resolve_ops_proxy_config_async,
通过 asyncio.to_thread 包装避免同步 DB 查询阻塞事件循环。
2026-03-17 18:06:15 +08:00
fawney19 8a21cb9a55 chore: bump aether-hub version to 0.1.8 2026-03-17 17:32:01 +08:00
fawney19 d0df52ce35 fix(hub): worker 断连时向 proxy 端发送 STREAM_ERROR 防止流阻塞
将 unregister_worker 中的 stream 清理逻辑提取为 cancel_streams_for_worker
方法,与 cancel_streams_for_proxy 形成对称设计。worker 断连时主动通知
proxy 端终止相关 stream,防止 proxy 的 stream handler 永久阻塞等待请求体。
添加单元测试覆盖该场景。
2026-03-17 17:29:12 +08:00
fawney19 c1ed42fd3a feat(body-rules): 支持按 provider_type 覆盖 cache-sensitive 保护字段集合
Codex 通过 OpenAI CLI/Compact 格式转发时,endpoint body_rules 需要能
修改 instructions/input/tools 等 prompt 字段。新增 provider_type 维度的
保护集合映射,Codex 场景仅保护 prompt_cache_key,其余 prompt 字段交由
body_rules 自由调整。
2026-03-17 17:09:39 +08:00
fawney19andLewisPen c4bb6b8161 feat(auth): 重构认证系统,引入 session 会话管理
- 新增 user_sessions 数据库表及 Alembic 迁移
- 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理)
- 认证流程改用 refresh token cookie + access token 双令牌模式
- 前端实现自动静默刷新、跨标签页同步及设备指纹
- 用户设置页新增会话管理和密码修改功能
- 管理员用户管理新增强制登出和会话查看
- 密码策略增强,支持强度校验和泄露检测
- OAuth 登录流程适配新会话机制
- 新增完整的单元测试和 API 测试覆盖

Closes #232

Co-authored-by: LewisPen <[email protected]>
2026-03-17 16:34:09 +08:00
fawney19 d480aa11f3 fix(request-body): 使用 deepcopy 防止请求体在处理流程中被意外修改
handler 基类和格式转换 registry 中,原始请求体通过浅拷贝或直接引用传递,
导致下游处理(模型映射、格式转换、重试整流)可能修改原始数据,
影响后续重试或并发请求的正确性。统一改用 copy.deepcopy 隔离副本。
2026-03-17 13:23:02 +08:00
fawney19 d63d5eff85 fix(nginx): 统一所有 location 块的 CF 头剥离,补充 proxy_hide_header 防止响应泄露 2026-03-17 11:33:43 +08:00
fawney19 5dae2a4792 fix(normalizer): 移除 Claude system 中的 billing header 2026-03-17 11:10:04 +08:00
fawney19 dcbd7dc219 feat(normalizer): 稳定请求体字段顺序以提升 prompt cache 命中率
在 FormatNormalizer 基类新增 _reorder_request_keys 方法,OpenAI/OpenAI CLI
normalizer 各自定义前缀字段顺序(model, tools, messages / model, instructions,
tools, input),确保静态字段前置、动态内容后置。同时调整 OpenAI CLI 中
instructions 字段的构建位置使其在 input 之前。
2026-03-17 10:26:57 +08:00
fawney19 2dcf8b8414 refactor(prompt-cache): 移除 client_family 命名空间拆分,统一缓存 key 提升复用率
不再按 User-Agent 客户端类型拆分 prompt cache namespace,
所有客户端共享同一缓存 key,版本升级至 v3。
2026-03-17 09:49:35 +08:00
fawney19 438f16094f refactor(stream): 将不完整流 token 估算逻辑收敛到 StreamContext
- 新增 has_partial_response / ensure_estimated_output_tokens / should_estimate_incomplete_tokens 方法
- CancelledError 路径在归因前即补充 output_tokens,确保日志包含估算值
- CLI Handler 和 Chat Handler 的兜底估算统一使用 should_estimate_incomplete_tokens
- 移除 cli_monitor_mixin 和 stream_telemetry 中重复的条件判断
- 新增对应单元测试
2026-03-17 03:37:06 +08:00
fawney19 40e0b82fa0 fix(chat-handler): 修复 _prepare_provider_request 缺少 original_headers 参数导致的 NameError 2026-03-17 03:21:19 +08:00
fawney19 b9b0a75fe4 feat(cache-fingerprint): 新增字段级指纹,支持逐字段 sha256 和字节数追踪
在请求缓存指纹中为每个 cache_relevant 字段单独计算 sha256 和字节数,
便于精确定位哪些字段发生了变化。fingerprint version 升级为 2。
2026-03-17 02:57:36 +08:00
fawney19 b6cc0bc3a7 refactor(usage): 统一 cache token 提取逻辑,新增请求缓存指纹记录
- 新增 extract_cache_read_tokens() 兼容 OpenAI/Claude/Gemini 多种字段命名
- parsers/stream_processor/cli_event_mixin 统一使用提取函数替换内联逻辑
- 同时兼容 prompt_tokens/completion_tokens (OpenAI) 和 input_tokens/output_tokens (Claude)
- 新增 cache_fingerprint 模块,在 telemetry 记录时自动计算并附带请求缓存指纹
- 新增对应单元测试
2026-03-17 02:34:32 +08:00
fawney19 d2f1431269 refactor(prompt-cache): 将 prompt_cache_key 生成从 Codex 专用模块提取为通用服务,支持 OpenAI 官方 API 和 Codex 端点
- 新增 prompt_cache.py 统一管理 prompt cache key 的生成逻辑
- 基于 User-Agent 识别客户端家族(openai_python/openai_node/codex_desktop 等),不同客户端生成不同 cache key
- 在 chat_handler_base/cli_stream_mixin/cli_sync_mixin 统一调用 maybe_patch_request_with_prompt_cache_key
- Codex request_patching 不再负责 prompt cache key 注入,仅保留内部标记清理
- 新增 is_official_openai_api_url 工具函数区分 OpenAI 官方 API 与兼容端点
2026-03-17 01:55:24 +08:00
fawney19 4ecaefbade refactor(conversion): body_rules 保护 cache-sensitive 字段,normalizer 保真优化与诊断日志
- RequestBuilder 新增 protected_body_keys 机制,按 provider API 格式阻止 body_rules
  改写 prompt cache 相关的顶层请求字段(messages/tools/system 等)
- Claude/Gemini normalizer 优先复用原始 raw tool_choice,避免 round-trip 丢失信息
- Claude normalizer 修复 content blocks 输出顺序(flush_text_parts),
  _coerce_claude_message_sequence 返回结构化诊断
- OpenAI normalizer 保留 raw tool call arguments 字符串与原始 tool 定义 extra 字段
- schema_utils allOf 合并保持 required 字段插入顺序
- 各转换环节增加结构化 debug 日志用于调试
2026-03-17 01:25:29 +08:00
fawney19 c97c9332eb feat(codex): 基于用户 API key 生成稳定的 prompt_cache_key,实现跨 provider key 的 prompt 缓存复用
- prepare_context 传递 user_api_key_id 到 CodexRequestContext
- wrap_request 中调用 patch_openai_cli_request_for_codex 注入 prompt_cache_key
- 客户端已提供 prompt_cache_key 时不覆盖
- 补充对应单元测试
2026-03-16 17:25:52 +08:00
fawney19 c070e5a9f6 fix(conversion): OpenAI CLI 流式转换 tool 调用与文本输出 block index 不再复用
引入统一的 block index 分配器(_allocate_block_index / _ensure_text_block_index),
避免工具调用后紧跟文本输出时 Claude block index 冲突。
2026-03-16 16:19:27 +08:00
fawney19 8cd3a69803 fix(oauth): ACCOUNT_BLOCK 标记不再禁用 key,token 刷新成功自动解除所有 invalid 标记
- error_handler 标记 ACCOUNT_BLOCK 时保持 is_active=True,oauth_invalid 标记已
  足够阻止调度,配额刷新仍可覆盖该 key
- 移除 is_account_level_block 守卫:token 刷新成功即证明账号可用,清除所有
  oauth_invalid 标记(含 ACCOUNT_BLOCK)
- health_policy 401 瞬时失败不再设置 60s cooldown,仅清除 token 缓存后立即重试
- key_quota_service 全量刷新时纳入 ACCOUNT_BLOCK 的 key,账号恢复后可自动解除
- 各 refresher 刷新成功时显式恢复 is_active=True
2026-03-16 15:50:41 +08:00
fawney19 791c9c98dc refactor(conversion): OpenAI Chat/Responses API 跨格式字段双向转换统一化
- 将工具/tool_choice/web_search/custom tool 的双向转换函数提取到 constants.py,
  openai.py 和 openai_cli.py 共享,消除两端逻辑不一致
- 新增 Chat <-> Responses 的 passthrough 字段白名单,支持 metadata/user/
  service_tier/prompt_cache_key 等字段跨格式透传
- 支持 text config (response_format + verbosity) 在 Chat/Responses 间互转
- 修复 Gemini json_schema 解包:OpenAI 的 {name, schema, strict} 包装层
  不再被整体传入 Gemini responseSchema
- 修复 reasoning_effort 优先级:显式 effort 优先于 budget_tokens 反推,
  避免 Claude output_config.effort 被覆盖
- Gemini 格式输出增加 web_search_options -> googleSearch 工具映射
- Claude schema validator 增加 web_search 类型工具的宽松校验
- 新增覆盖测试:custom tool/tool_choice、allowed_tools、web_search 双向转换、
  text config 映射、passthrough 字段保留、跨格式 schema 校验
2026-03-16 14:21:14 +08:00
fawney19 025e979935 fix(codex): 配额刷新 401/403 不再自动禁用 key,区分软性请求失败与账户封禁
- 新增 OAUTH_REQUEST_FAILED_PREFIX 标记非 token 失效的 403 请求失败
- 引入 _merge_invalid_reason 合并逻辑,避免低优先级原因覆盖高优先级状态
- account_state 中 REFRESH_FAILED/REQUEST_FAILED 前缀不再触发封禁判定
- 401/403 返回 auto_disabled=False,不再直接设置 is_active=False
2026-03-16 01:12:35 +08:00
fawney19 131471a13f refactor(maintenance): body 压缩改为逐条独立事务,降低内存占用与锁粒度
将 _cleanup_body_fields 从批量加载完整记录改为先查询 ID 列表,
再逐条独立会话处理压缩,避免大批量事务导致的内存和锁问题。
批次大小上限从 100 降至 25,新增排序保证处理顺序确定性。
2026-03-15 23:46:01 +08:00
fawney19 7ff63077c3 feat(providers): provider 摘要列表排序增加启用状态优先
后端查询和前端展示均按 is_active 降序、priority 升序、created_at 升序排列,
确保已启用的 provider 始终排在前面。
2026-03-15 23:13:11 +08:00
fawney19 faba0cbd07 fix(openai-cli): item_id 与 call_id 不一致时 tool delta 映射错误
Responses API 中 function_call 的 item.id 和 call_id 可能不同,
增加别名注册和解析机制,确保后续 delta/done 事件统一使用 call_id。
2026-03-15 22:50:50 +08:00
fawney19 75f17935f9 fix(openai-cli): 补全 function_call 的 arguments 快照同步,修复无 delta 时参数丢失
在 output_item.done 和 function_call_arguments.done 事件中,通过 args snapshot
对比已发送的增量,补发缺失的 tool arguments delta,确保客户端收到完整参数。
2026-03-15 22:20:54 +08:00
fawney19 60842fbbb5 fix(openai): tool_call delta 重复携带 function.name,增强严格客户端兼容性
在 ToolCallStart 时记录 block_index 到 tool_name 的映射,
ToolCallDelta 时同步输出 function.name,避免严格客户端丢弃无 name 的 delta。
提取 _ss_dict 辅助方法统一 stream state 中 dict 字段的初始化逻辑。
2026-03-15 22:04:21 +08:00
fawney19 d58c27d22d fix(openai): tool_call delta 重复携带 id/type,修复严格客户端兼容性
部分 OpenAI 兼容客户端要求每个 tool_call delta chunk 都包含 id 和 type
字段,否则会将后续 delta 视为无效。通过 block_to_tool_id 映射在
ContentBlockStart 时记录 tool_id,确保后续 ToolCallDelta 能正确回填。
2026-03-15 21:51:44 +08:00
fawney19 65550159bb refactor(frontend): 优化条件编辑器 UI,限制嵌套深度为两层
- 顶层组合条件增加 ListFilter 图标按钮,可转回单条件
- 子组隐藏"+ 子组"按钮,防止无意义的深层嵌套
- 嵌套叶子节点隐藏转组合按钮,保持最多两层结构
2026-03-15 21:02:05 +08:00
fawney19 3c7ad81d62 feat(rules): 条件系统增强,支持 all/any 组合条件和 original/current 数据源切换
- evaluate_condition 支持递归 all/any 组合节点和 source 字段
- header_rules 支持 condition 条件触发,HeaderBuilder.apply_rules 透传 body/original_body
- 提取 EndpointConditionEditor 组件统一请求头/请求体规则的条件编辑 UI
- header_rules 新增服务端结构校验(action/key/from/to/condition)
- 新增组合条件、source 切换、fail-closed 等测试用例
2026-03-15 20:27:53 +08:00
fawney19 6b23c9b3ce fix(frontend): 使用记录表格"命中率"列名改为"缓存命中率" 2026-03-15 18:32:42 +08:00
fawney19 900e54d740 feat(conversion): 支持 Claude output_config.effort 跨格式转换,新增 xhigh 档位
- 新增 Claude output_config.effort 与标准化 reasoning_effort 的双向映射
- 新增 xhigh 档位(budget_tokens=8192),对应 Claude effort=max
- reasoning_effort 独立于 thinking 存入 extra,支持无 thinking 场景的跨格式传递
- OpenAI/Responses API 输出时 xhigh 自动降级为 high
2026-03-15 17:57:12 +08:00
fawney19 8cc70934da fix(openai,oauth): 修复 resp_ ID 前缀转换和 token 失效误判为账号级 block
- OpenAI normalizer: resp_ 前缀 ID 规范化为 chatcmpl- 前缀,流式 tool_call index 增加 block_index 回落
- OAuth token: 提取 token 失效关键词为共享常量,token invalidated 不再被判定为账号级 block
- Codex refresher: 403 + token invalidated 标记为 OAUTH_EXPIRED,允许 refresh_token 恢复
2026-03-15 16:56:58 +08:00
fawney19andLewisPen f92b0943b5 feat(rate-limit): 实现分层 RPM 限速,支持系统默认/用户/独立Key三级配置
- 新增用户级 rate_limit 字段,支持系统默认/用户自定义/不限制三种模式
- 独立 Key 的 rate_limit 语义调整:null=跟随系统默认,0=不限制,>0=自定义
- 实现 UserRpmLimiter 基于 Redis sliding window 的 RPM 限速引擎
- Pipeline 请求流程集成用户级 RPM 检查
- 管理后台和用户面板新增 RPM 限速配置与实时状态查看
- 系统设置新增全局默认 RPM 配置项
- 迁移脚本回填现有 API Key 的 rate_limit 默认值
- 新增用户/Key RPM 状态监控 API 和前端展示

Closes #231

Co-authored-by: LewisPen <[email protected]>
2026-03-15 14:22:59 +08:00
fawney19 920a383136 refactor(claude-code): 移除 TLS 指纹模拟配置,简化上下文构建逻辑
移除 enable_tls_fingerprint 配置项、TLS_PROFILE_CLAUDE_CODE 常量及
resolve_claude_code_tls_profile 函数,TLS profile 改为仅由 fingerprint
的 impersonate 字段决定,简化 build_and_set_claude_code_request_context
返回值为单一 context 对象。
2026-03-15 00:23:19 +08:00
fawney19 693e37d2df fix(frontend): 统计表格缓存列仅显示缓存读取token,与命中率计算口径一致
缓存列移除cache_creation_tokens,只保留cache_read_tokens,
避免缓存值大于输入值的反直觉展示。
2026-03-14 16:39:48 +08:00
fawney19 9c6036a103 fix(frontend): 统计表格token列防止换行,缓存token合并为总值显示 2026-03-14 16:26:07 +08:00
fawney19 751a4d9111 feat(usage): 统计表格新增输入/输出与缓存创建token细分展示
- 后端接口(admin/user_me/query)新增 output_tokens、cache_creation_tokens、total_input_context 字段返回
- 前端统计表格(模型/提供商/API格式)合并 Tokens 列为"输入/输出"+"缓存读取/创建"两行紧凑布局
- 合并"缓存Token"和"缓存命中率"为单一"命中率"列,减少表格宽度
- 修正 UsageRecordsTable 缩进及模板格式
2026-03-14 16:11:56 +08:00
fawney19 aafd332198 revert(frontend): 使用记录表格恢复格式与类型为独立列 2026-03-14 15:01:38 +08:00
fawney19 337cd0c505 fix(frontend): 修复模型缓存价格设为0时不生效的问题
缓存价格更新函数使用 numValue > 0 判断是否手动设置,
导致输入 0 时被错误地当作清空处理。改为检查原始输入
是否为空字符串/null/undefined 来区分清空与有效输入。
2026-03-14 14:32:58 +08:00
fawney19 b15ce9977a feat(frontend): 用量页面 UI 优化 - 合并格式/类型列、分析面板可折叠
- UsageRecordsTable: 合并 API 格式与类型为单列,缩减列宽使表格更紧凑
- MainLayout: 添加 header 级 Teleport 插入点供子页面注入操作按钮
- Usage: 用量分析面板改为可折叠,折叠状态持久化到 localStorage
2026-03-14 13:28:56 +08:00
fawney19andAAEE86 e0286aebe3 refactor: 共享请求管道、按需懒加载、流式内存护栏与连接池治理
- 抽取 ApiRequestPipeline 单例,44 个路由文件共享同一实例
- Handler/Adapter 模块级 __getattr__ 延迟导入,减少启动时间
- 新增 ensure_stream_buffer_limit() 流式内存护栏(16MB 单行 / 32MB 总量)
- HTTP 空闲连接清理与 curl_cffi LRU 会话池
- ensure_providers_bootstrapped 按需引导指定 provider_types
- Usage 事件序列化迁移至 msgpack,Redis codec 隔离
- 启动预热任务(/readyz 就绪门控)与优雅关闭
- 通知邮件模块独立开关与 SMTP 配置校验
- CryptoService DCL 线程安全修复
- 通知模块开关 DB 查询 30s 内存缓存
- /readyz 对 unknown 状态返回 503
- 预热关闭 5s 超时保护
- 预热适配器逐个 try-except 容错
- FormatConversionRegistry 哨兵模式防并发重复物化
- 流式缓冲检查无条件执行

Closes #230

Co-authored-by: AAEE86 <[email protected]>
2026-03-14 11:59:07 +08:00
fawney19 45985f1c04 Merge pull request #228 from NyaDoo/fix/config-timezone-and-key-dialog
fix: APP_TIMEZONE 循环导入 & 独立密钥额度编辑空白
2026-03-14 01:42:28 +08:00
fawney19andEntropy-Xu 776dd2f8ea feat(cleanup): 解耦 request_candidates 与 provider_api_keys 生命周期
- 移除 request_candidates.key_id 对 provider_api_keys 的外键约束(含迁移脚本)
- 删除 Key 时不再级联删除候选记录,改为独立按保留天数定时清理
- 新增 request_candidates_retention_days / request_candidates_cleanup_batch_size 配置项
- batch_delete_task 增加 lock_timeout 及超时自动降批重试机制
- cleanup_key_references 提取阶段化清理流程,移除 RequestCandidate 联动删除
- 前端 CleanupPolicySection 新增候选记录保留天数和清理批次配置

Closes #227

Co-authored-by: Entropy-Xu <[email protected]>
2026-03-14 01:33:08 +08:00
fawney19andAAEE86 bdfe4adc98 feat(usage): 修复缓存命中率计算并新增用户端 API 格式统计
- 新增 input_context_expr() 按 api_format 区分 input_tokens 语义
  (OpenAI/Gemini input_tokens 已含 cache_read,Claude 需额外加上)
- 缓存命中率统一改为基于归一化后的 total_input_context 计算
- 用户 /me/usage 接口新增 summary_by_api_format 后端聚合字段
- 前端 API 格式统计改用后端聚合数据,移除前端逐条记录手动统计
- 提取 formatHitRate 到 utils/format.ts 消除三处重复定义
- 移除 PoolManager 中未使用的 select_key 方法

Co-Authored-By: AAEE86 <[email protected]>
2026-03-14 00:33:19 +08:00
LewisPen 00a0371997 fix(frontend): 独立密钥编辑模式额度区域空白问题
v-else-if 条件遗漏导致编辑模式下非 unlimited 的密钥
额度区域既不显示 Input 也不显示提示文本,对齐 UserFormDialog
的 v-else 逻辑。
2026-03-13 10:58:34 +08:00
LewisPen ded6b5b081 fix(config): 统一 APP_TIMEZONE 至 Config 类,修复 wallet 循环导入
将散落在 scheduler / stats_aggregator / daily_usage_ledger / routes
中的 os.getenv("APP_TIMEZONE") 收归 Config.app_timezone,消除
wallet → system → maintenance_scheduler → user.preference → wallet
的循环导入链。
2026-03-13 10:54:21 +08:00
fawney19 e9678ea899 fix(admin): 优先级脏检查、base_url 校验、usage detail 延迟加载及导入数据验证
- 前端优先级管理: 保存时对比原始快照,仅提交实际变更的 provider/key 优先级,
  并限制并发请求数(SAVE_CONCURRENCY=6),避免无效 API 调用
- handler_adapter_base: _normalize_test_base_url 改为 _validate_test_base_url,
  移除对 dict 类型 base_url 的兼容,严格要求字符串输入
- provider_query: 新增 _require_test_endpoint_base_url,在测试链路提前校验
  endpoint.base_url 类型和非空
- system.py: 导入 endpoint 时通过 ProviderEndpointCreate 模型校验数据,
  拒绝非法 base_url 类型(如 dict)
- usage detail: 使用 defer() 延迟加载 body 列,通过 SQL CASE 表达式在
  数据库端计算 has_*_body 标记,减少不必要的大字段传输
- provider routes: 新建 provider 时 priority=0 边界处理,clamp 并 shift
2026-03-12 17:11:14 +08:00
fawney19 8d69f72e2a fix(stability): 健康监控 DB 操作异步化,防止 worker 超时崩溃
- executor: record_success 通过 asyncio.to_thread offload 到线程池
- error_handler: 3 处 record_failure 同样 offload 到线程池
- sync_execute: 设置 expire_on_commit=False 防止 commit 后 ORM 懒加载
- handler_adapter_base: 归一化 check_endpoint 的 base_url 输入
2026-03-12 16:17:22 +08:00
fawney19 127b4e11de ci(hub): 移除 build-hub workflow 中的 Docker 构建和推送步骤 2026-03-12 15:34:09 +08:00
fawney19 22093bed4d chore: bump aether-hub version to 0.1.7 2026-03-12 15:21:47 +08:00
fawney19 ebd53ad679 fix(hub): 修复 worker 连接清理逻辑,按完成顺序有序取消对端任务 2026-03-12 15:17:00 +08:00
fawney19 280c604327 移除deplpy.sh中每次自动拉取最新代码, 以便于回退版本 2026-03-12 14:57:15 +08:00
fawney19 ad31cdbf85 perf(stability): batch committer 异步化及降级冷却参数调优
- 将 batch_committer 的 DB commit 操作通过 asyncio.to_thread 移至线程池,避免阻塞事件循环
- 将 f-string 日志替换为 loguru 惰性格式化
- 降低事件循环延迟降级的冷却时间和乘数,加快降级恢复
2026-03-12 14:22:55 +08:00
fawney19 0112ab752b refactor(proxy): 将 proxy resolver 同步阻塞操作异步化,避免阻塞事件循环
- 为 resolve_proxy_info、resolve_delegate_config、build_proxy_url、
  get_system_proxy_config、build_post_kwargs、build_stream_kwargs 新增
  _async 异步版本,通过 asyncio.to_thread 在工作线程中执行同步 DB 查询
- 为 _proxy_node_cache 和 _system_proxy_cache 添加 threading.Lock 保护
  多线程并发读写安全
- 大 payload 的 gzip 压缩超过 64KB 阈值时走线程池,小 payload 仍在事件
  循环中同步执行以避免不必要的线程调度开销
- hub_transport 的 frame 压缩同样增加异步版本
- 删除已无调用者的同步方法 create_client_with_proxy,将其逻辑内联至
  get_upstream_client 并改为异步
- 更新所有 handler/executor/failover 调用点使用新的异步 API
- 补充 async 版本的单元测试
2026-03-12 13:59:21 +08:00
fawney19 66fec80e79 fix(frontend): 修复用户表单密码框无法输入及编辑模式额度框不显示
- 密码框: masked 属性改为始终开启,避免聚焦时 DOM 重建导致焦点丢失
- 额度框: 编辑模式下 unlimited=false 时显示"按钱包余额限制"而非空白
2026-03-12 13:31:43 +08:00
fawney19 fddfaecf5e chore: bump aether-hub version to 0.1.6 2026-03-12 12:21:52 +08:00
fawney19 71ae1a2307 feat(hub,stability): bounded outbound queue、worker liveness 检测、事件循环 watchdog 及 DB 操作异步化
- aether-hub: unbounded channel 改为 bounded channel (BoundedOutbound),队列满时标记拥塞并主动关闭连接,防止内存无限增长
- aether-hub: worker idle timeout 从命令行参数改为基于心跳的 liveness 检测,默认 60 秒
- aether-hub: 新增 ConnConfig 统一管理连接配置,新增 outbound_queue_capacity 参数
- hub_transport: 新增事件循环 watchdog,检测 lag 超过阈值时临时降级暂停新流
- gunicorn_conf: 启用 faulthandler,worker abort 时自动 dump 全部线程栈用于诊断
- health/endpoint_checker/recording: 同步 DB 操作移至 asyncio.to_thread,避免阻塞事件循环
- Dockerfile: 移除 --worker-idle-timeout 0 命令行参数,改由环境变量和默认值控制
2026-03-12 12:19:04 +08:00
fawney19 4d338ebd3d fix(priority): 允许号池聚合项在全局 Key 优先级管理中拖拽和编辑排序
之前号池(Pool)类型的 Key 在优先级管理对话框中被禁止拖拽和编辑优先级,
用户只能去号池高级设置中单独修改 global_priority,体验不一致。

现在号池聚合项可以像普通 Key 一样参与拖拽排序和点击编辑优先级,
变更会同步到 provider.pool_advanced.global_priority 并在保存时持久化。
2026-03-12 11:44:19 +08:00
fawney19 dc440f1507 fix(ci): 修正 deploy-pages action 版本号 (deploy-pages@v4, upload-pages-artifact@v3) 2026-03-12 10:41:26 +08:00
fawney19 5c732f844a chore(ci): 升级 GitHub Actions 版本,解决 Node.js 20 弃用警告
- actions/checkout v4 → v5
- actions/upload-artifact v4 → v5
- actions/download-artifact v4 → v5
- actions/setup-node v4 → v5
- actions/configure-pages v4 → v5
- actions/upload-pages-artifact v3 → v4
- actions/deploy-pages v4 → v5
- docker/build-push-action v5 → v6
- Node.js 版本 20 → 22
2026-03-12 10:34:29 +08:00
fawney19 c4044ba0b1 chore: bump aether-hub version to 0.1.5 2026-03-12 10:20:42 +08:00
fawney19 a8159b7bda fix(tunnel): 禁用 idle timeout 默认值,防止空闲 tunnel 连接被断开
Hub 端 proxy_idle_timeout 和 worker_idle_timeout 默认改为 0(禁用),
直连模式 proxy_tunnel.py 同步调整。连接存活检测依赖 PING/PONG 心跳。
2026-03-12 10:07:41 +08:00
fawney19 0ab20be667 fix(proxy): 抑制 tunnel/hub 高频重复日志,增加快速断开退避机制
- proxy_tunnel: idle timeout 日志首次 warning,后续降级为 debug/info 汇总
- hub_transport: 连续断开日志降级,追踪快速断开并调整重连初始 delay
- tunnel_manager: connect/disconnect 日志按 reconnect 计数分级输出
2026-03-12 09:56:34 +08:00
fawney19 3f048d373f refactor: 将路由层同步 DB 操作移至线程池执行,统一认证工具函数
- 管理端和用户端路由中的同步数据库操作提取为独立函数,通过 run_in_threadpool
  在线程池中执行,避免阻塞事件循环(涉及 api_keys、payments、users、wallets、
  provider_oauth、system、user_me、wallet 等模块)
- 抽取 authenticate_user_from_bearer_token 统一 token 验证逻辑,支持
  ManagementToken 和 JWT 两种认证方式,消除多处重复代码
- key_command_service 的 CRUD 操作改为线程池执行
- maintenance_scheduler 定时任务中的数据库操作改用 asyncio.to_thread
- Dockerfile 中 aether-hub 增加 --worker-idle-timeout 0 防止空闲断连
- 新增 test_api_auth_conventions 和 test_auth_utils 单元测试
2026-03-12 09:33:24 +08:00
fawney19 8b49a3d264 fix(frontend): 优化用户表单密码输入框,使用 masked 和 disable-autofill 属性简化实现 2026-03-12 01:46:41 +08:00
fawney19 6e51a3f45d feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进
- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用
- 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整
- aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s
- OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突
- ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环
- 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化
- 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义
- 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化
- 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用
- 补充相关测试用例
2026-03-12 01:11:35 +08:00
fawney19 0d770d1c4d feat(oauth): 新增 Codex account_user_id 和 organizations 字段采集、展示与判重
- 从 Codex id_token claims 和 token_response 中提取 account_user_id 和 organizations
- OAuth 判重逻辑改为优先按 account_user_id 匹配,支持同用户不同 Team 不误判
- 号池和 Provider 详情页展示组织标签、account ID 和 account_user_id
- 前端重复的 OAuth identity 工具函数提取到 utils/oauthIdentity.ts
- 后端重复的 normalize_oauth_organizations 提取到 core/provider_oauth_utils.py
2026-03-11 21:37:08 +08:00
fawney19 b45f021bba feat(pool): 批量操作对话框改为服务端分页筛选,新增凭据导出功能
- 批量操作对话框从全量加载改为服务端分页+筛选,支持搜索和快捷选择器的服务端过滤
- 新增 resolve-selection API,支持"全选筛选结果"时解析完整匹配列表
- 新增批量导出凭据功能(仅 OAuth 账号),并发下载后导出为 JSON 文件
- 将快捷选择器和全文搜索的匹配逻辑从前端迁移到后端,统一复用
- 提取 pool key 序列化与过滤的公共函数,消除 AdminListPoolKeysAdapter 中的重复代码
- entrypoint.sh 增加 PostgreSQL 就绪等待,避免数据库未启动时迁移失败
2026-03-11 19:32:19 +08:00
fawney19 1a1bce3e8c Merge pull request #224 from AAEE86/fix/pool-last-used-at-display
fix(usage,pool): 修复号池最后使用时间不更新
2026-03-11 16:33:36 +08:00
fawney19 6aeb5d40ab Merge pull request #222 from AAEE86/fix/provider-mapping
fix(provider-mapping): 修复详情页映射延迟刷新并补齐 mapping-preview 缓存治理
2026-03-11 16:17:48 +08:00
AAEE86 31ef2d134e fix(usage,pool): 修复号池最后使用时间不更新
统一在 ProviderAPIKey 累计更新时刷新 last_used_at/updated_at,即使 token/cost 增量为 0。

补充回归测试:覆盖 zero delta 场景和 provider_api_key_id 为空时跳过更新。
2026-03-11 16:12:36 +08:00
fawney19 85b50e67e1 Merge pull request #221 from AAEE86/fix/frontend
fix(frontend): 修复健康监控中 OpenAI Compact 百分比换行
2026-03-11 16:02:38 +08:00
fawney19 9353f89af0 Merge pull request #220 from AAEE86/master
fix(pool): recent_refresh 按 provider_type 解析 reset_seconds 并锁定 Codex 周重置语义
2026-03-11 16:02:14 +08:00
fawney19andAAEE86 380d69e096 feat(pool): 记录并展示 Provider Key 累计 Token 与费用
Closes #219

Co-authored-by: AAEE86 <[email protected]>
2026-03-11 15:56:58 +08:00
fawney19 02e2f4f500 fix: 修复钱包迁移脚本 _to_decimal 遇到 NaN/Infinity 值时的 InvalidOperation 异常 2026-03-11 15:22:27 +08:00
fawney19 0dbfefa834 Merge branch 'feat/wallet-billing-state-machine-and-daily-usage' 2026-03-11 15:15:01 +08:00
fawney19andLewisPen 04ab4bd9f2 feat: 强化用量计费状态机,新增钱包每日消费汇总分类账
- 将 usage.billing_status 默认值从 settled 改为 pending,完善
  pending -> settled/void 的状态转换逻辑,确保终态不可逆
- 新增 WalletDailyUsageLedger 模型和聚合服务,按账单日汇总
  每个钱包的消费金额、请求数和 token 用量
- 前端钱包中心页面集成每日消费流水展示,支持与充值记录混合
  排序和分页
- 新增两个数据库迁移:修复历史数据状态一致性、创建每日汇总表
- 补充计费状态机单元测试

Closes #218

Co-authored-by: LewisPen <[email protected]>
2026-03-11 15:11:33 +08:00
AAEE86 4955166b85 fix(provider-mapping): 修复详情页映射延迟刷新并补齐 mapping-preview 缓存治理
- 前端:Provider 详情页在 key/模型关联/模型保存/映射保存后并行刷新 endpoints 与 mapping-preview
- 前端:为 mapping-preview 请求增加 requestId 保护,避免旧响应回写覆盖新状态
- 后端:Key.allowed_models 变更时按 provider 精准失效 mapping-preview 缓存
- 后端:GlobalModel 变更(含创建)时全量失效 mapping-preview 缓存
- 监控:在 Redis 缓存分类中新增 provider_mapping_preview(admin:providers:mapping-preview:*)

验证:
- frontend `npm run type-check`
- backend `python -m compileall`(相关文件)
- pytest 定向用例通过
2026-03-11 09:58:25 +08:00
fawney19 6235c772ac fix: 修正 PR #217 合并后的两个细节问题
- user_me usage 接口文档注释补充 summary_by_provider 字段说明
- 导出配置中 internal_priority 为 NULL 时映射为 inf,保持与原 SQL NULLS LAST 一致
2026-03-10 23:33:02 +08:00
fawney19 e2ec3f7942 Merge pull request #217 from AAEE86/1233
perf: 优化请求鉴权链路并批量化统计/调度查询
2026-03-10 23:31:28 +08:00
fawney19andEntropy.Xu a816235efb feat: 新增 Gemini CLI provider adapter
Closes #216

Co-authored-by: Entropy.Xu <[email protected]>
2026-03-10 23:15:05 +08:00
fawney19andEntropy.Xu 1e39ab3c2e feat: 新增 Gemini CLI provider adapter
- 新增 gemini_cli adapter 包(client/constants/envelope/plugin/quota)
- 实现 v1internal 协议封装、OAuth enrichment、loadCodeAssist/onboardUser 流程
- 实现配额耗尽检测与冷却元数据管理(RESOURCE_EXHAUSTED 解析)
- 新增 GeminiCliQuotaReader 支持按模型粒度的配额展示
- endpoint check 支持流式回退和 v1internal 响应解包
- health_policy 429 处理增加 Google 配额冷却解析
- error_handler 在限流时同步 Gemini CLI 配额状态
- 前端添加 Gemini CLI provider 类型选项和 OAuth 图标
- 新增 preset models(gemini-2.5-pro/flash, gemini-3-pro/flash, gemini-3.1-pro)
- 新增 gemini_cli quota 单元测试

Closes #216

Co-authored-by: Entropy.Xu <[email protected]>
2026-03-10 23:14:05 +08:00
fawney19 85aa66c76d refactor: 优化 pending 清理和压缩任务的内存使用
- 将 pending 请求清理改为分批处理,使用轻量列查询代替全量 ORM 加载
- 限制 pending 清理和历史压缩的批次大小上限,防止单次查询占用过多内存
- 移除 processed_ids 集合,改用 synchronize_session=False 避免内存累积
- 更新 README 中部署方式描述
2026-03-10 18:11:08 +08:00
AAEE86 9a4817faf8 fix(frontend): 修复健康监控中 OpenAI Compact 百分比换行
- 调整 HealthMonitorCard 左侧信息区宽度(sm:w-44 -> sm:w-52)
- 为 API 格式与成功率 Badge 添加 whitespace-nowrap,避免文本断行
2026-03-10 16:50:26 +08:00
fawney19 7b0c80a0c4 refactor: 增加内存保护措施,精简启动任务
- 流式响应不再存储完整文本,仅记录长度用于 token 估算
- complete_response 文本累积增加 64KB 上限保护
- 通知缓冲区(email/webhook)增加溢出保护,超限丢弃旧通知
- 熔断器淘汰策略改进,全部 open/half-open 时按最旧失败时间淘汰
- 移除启动时清理任务和统计聚合回填,减少启动负担
- token 估算简化为基于长度的方法,避免持有完整文本
2026-03-10 16:08:11 +08:00
fawney19 6ec8df97e8 refactor: 限制流式文本收集内存增长,降低默认连接池和缓存上限
- StreamContext.append_text 增加 16KB 上限,超出后仅计数不存储,
  避免长流式响应导致内存持续增长;token 估算改用 collected_text_length
- 降低 DB 连接池上限 (30->15) 和 HTTP 连接池上限 (200->100)
- tiktoken 编码器缓存从 32 缩减到 4(实际编码种类只有几种)
- dev.sh 添加开发环境低配连接池默认值,uvicorn 热重载仅监视 src 目录
2026-03-10 15:33:46 +08:00
AAEE86 f82964217e fix(pool): recent_refresh 按 provider_type 解析 reset_seconds 并锁定 Codex 周重置语义
- 为 `extract_reset_seconds` 增加 `provider_type` 解析链路(显式参数 -> key.provider_type -> key.provider.provider_type -> metadata 推断)。
- Codex 场景固定读取 `primary_reset_seconds`(周限额),避免误用 `secondary_reset_seconds`(5 小时窗口);Kiro/Antigravity 继续走统一 quota reader。
- 在 `RecentRefreshDimension` 和 `PoolManager` 的策略上下文中透传 `provider_type`,并在缺失时从 key provider 回退推断。
- 新增/补强测试覆盖:
  - multi_score `recent_refresh` 在 Codex 下按 weekly reset 排序;
  - quota reader helper 在 Codex provider 下返回 primary reset;
  - Codex 付费计划(plus/enterprise)在 headers 与 WHAM 响应中的窗口映射与主次窗口对齐(10080/300 分钟)。

降低 recent_refresh 评分偏差风险,并为 Codex 付费窗口解析提供回归保护。
2026-03-10 14:55:48 +08:00
fawney19 cfa5535f6e refactor: 引入 safe_create_task 防止后台任务被 GC 回收,降低默认连接池和 worker 数量
- 新增 safe_create_task 统一替代裸 asyncio.create_task,通过全局集合持有 task 引用
- 默认 worker 数量从 4 降为 1,HTTP 连接池总预算从 800 降为 200
- 为 health_cache 和 affinity_manager 内存缓存增加上限淘汰机制
- MemoryCachePlugin 支持延迟启动清理任务
- gunicorn when_ready 增加 gc.collect() 并记录 post_worker_init RSS
2026-03-10 14:42:33 +08:00
fawney19 2d846b2c58 refactor: 移除启动缓存预热功能
删除 cache_warmup.py 及相关配置项和测试,简化启动流程
2026-03-10 14:11:41 +08:00
fawney19 f40e8037dd feat: 添加启动任务开关,修复统计聚合内存泄漏
- 新增 CACHE_WARMUP_ENABLED 和 MAINTENANCE_STARTUP_TASKS_ENABLED 环境变量,
  允许禁用缓存预热和维护调度器启动任务
- 在统计聚合批量处理循环中添加 db.expunge_all(),释放 Session identity map,
  防止 ORM 对象累积导致内存暴涨
- 添加启动任务开关的单元测试
2026-03-10 13:42:38 +08:00
fawney19 2b21a75982 refactor: 优化模型获取调度器并发模型和缓存格式
- 用固定 worker 数的队列消费模式替换 Semaphore+gather,避免大号池一次性创建大量协程
- 分批扫描 Key ID(keyset pagination),避免一次性加载全量 ID
- 上游模型缓存从 per-api_format 去重改为 per-model-id 聚合 api_formats 列表,减少 Redis 占用
- 启动阶段支持环境变量控制(MODEL_FETCH_STARTUP_ENABLED / MODEL_FETCH_STARTUP_DELAY_SECONDS)
- 新增单元测试覆盖聚合逻辑和并发上限验证
2026-03-10 12:47:50 +08:00
fawney19 9ee27308db refactor: defer ProviderAPIKey 大 JSON 字段,减少调度器和模型获取的内存占用
- candidate_builder: defer adjustment_history/utilization_samples/upstream_metadata,
  号池路径在释放 DB 连接前预计算 _pool_account_state 避免后续 N+1 查询
- pool/manager: 优先读取预计算的 _pool_account_state,fallback 到实时解析
- fetch_scheduler: 三处 ProviderAPIKey 查询改用 defer/load_only 排除无关字段
2026-03-10 12:33:15 +08:00
fawney19 1518223de6 refactor: 优化调度器内存占用,增加 session 全局清理
- main: 启动阶段 Provider 查询改用聚合查询,避免全量加载 ORM 对象
- envelope: Claude Code session 增加全局定时清理,防止 scope_key 无界增长
- health_cache: 增量更新时清理已移除的 stale key 条目
- aware_scheduler: 日志中用 key.name 替代 api_key 后四位,避免泄露凭证片段
- candidate_builder: defer api_key/auth_config 等冷字段,减少调度热路径内存占用
2026-03-10 11:40:29 +08:00
fawney19 3063938a82 refactor: 优化调度器并发与内存占用,修复循环依赖
- fetch_scheduler: 模型获取从串行改为 Semaphore 限并发并行
- pool_quota_probe_scheduler: 取消启动时立即探测避免阻塞,
  改为逐 provider 独立查询 key 避免一次性加载全部到内存,
  移除未使用的 _ProviderProbeTask 数据类
- proxy_node/__init__: 移除 health_scheduler 导入避免循环依赖
2026-03-10 11:22:11 +08:00
fawney19 86449cae52 refactor: 迁移文件内联 helpers,删除共享 alembic/helpers.py
将 _SchemaCache、replace_fk_if_needed、batch_alter_type 等辅助函数
内联到各迁移文件中,使每个迁移自包含、不依赖外部模块。
同时修正 a3f1b7c9d2e4 的 down_revision 为 d7649c1f8e21。
2026-03-10 10:53:41 +08:00
fawney19 7c580e843f fix: 治理 Prometheus 指标基数爆炸和内存缓存无界增长
- 移除 token/latency Prometheus 指标的 model 标签,避免 provider x model 笛卡尔积
- HealthMonitor 滑动窗口从 DB JSON 迁移至进程内存,减少写放大
- ModelCostService 三层缓存增加 500 条上限,超限时清空
- StickyPriority 粘性缓存和健康状态字典增加容量淘汰
- AffinityManager 请求锁字典增加 500 条上限,淘汰空闲锁
- 配额刷新/探测查询使用 defer/load_only 避免加载大 JSON 列
- Alembic 迁移清理 DB 中遗留的 request_results_window 数据
- 同步更新测试适配 batch_get_cooldowns 返回值和批量删除异步化
2026-03-10 10:40:50 +08:00
fawney19 c9f0685b40 fix: 治理 Prometheus 指标基数爆炸和内存缓存无界增长
- 删除高基数标签 key_id/model,移除未使用的指标 concurrency_slots_in_use/streaming_request_duration_seconds
- HTTP 客户端池:命名客户端添加 LRU 淘汰上限,tunnel 客户端添加 LRU 淘汰和 last_used_time 追踪
- ResilienceManager:last_errors 改用 deque(maxlen),error_stats/circuit_breakers 添加上限淘汰
- HealthMonitor:_circuit_history 改用 deque(maxlen)
- ProviderHealthTracker:清理已无记录的过期 key
- PrometheusPlugin:动态指标数量添加上限,超限后拒绝创建
- 中间件使用路由模板替代实际路径,防止动态路径段导致标签爆炸
2026-03-10 09:52:21 +08:00
fawney19 afd0dcf2ff feat: OAuth 导入完成后自动触发配额刷新,统一配额常量定义
- 单个导入、批量导入、Kiro device flow 完成后后台异步刷新配额
- 将 CODEX_WHAM_USAGE_URL 和 QUOTA_REFRESH_PROVIDER_TYPES 统一到 key_quota_service.py,消除 3 处重复硬编码
2026-03-10 09:36:47 +08:00
AAEE86 68d4df71d8 perf(frontend): 优化图表更新链路与缓存监控倒计时开销
- 收敛 LineChart 的配置构建逻辑,复用 options 生成函数
- 将 LineChart 的 data/options 监听从深监听改为引用监听
- 统一使用 chart.update('none'),减少不必要的动画与重绘

- 为 ScatterChart 新增 prepareRenderData 流程,合并间隙压缩与点位转换
- 消除 createChart/updateChart 中重复的数据预处理逻辑
- 将散点图更新改为监听 data、compressGaps、gapThreshold、compressedGapSize
- 移除 compressGaps 切换时的 destroy + recreate 路径,改为原图更新
- 将散点图 options 更新改为无动画刷新,降低全量重算成本

- 为缓存监控页新增 nextExpireAt 状态,跟踪最近过期时间
- 在拉取 affinity 列表后立即按当前时间裁剪已过期数据
- 将每秒全表 filter 改为按最近过期时间触发清理
- 页面恢复可见时先补执行过期清理,再恢复倒计时
- 保留每秒 currentTime 更新,仅用于倒计时显示,降低常驻扫描开销
2026-03-10 09:09:06 +08:00
AAEE86 596227659a perf(admin-usage): 精简管理员 Usage 列表的 request_metadata 下发
- 从 request_metadata JSON 中直接查询 model_version
- 管理员 Usage 列表不再返回完整 request_metadata
- 前端表格、类型和 mock 改为使用顶层 model_version
- 新增轻量响应回归测试
2026-03-10 00:02:49 +08:00
fawney19 3b0dbadb1e fix: 单个提供商更新时不再清空全部余额缓存
loadBalances 新增 fullReload 参数,单个提供商刷新时传入 false,
避免更新一个提供商的余额数据时清除其他提供商的已加载余额。
2026-03-09 23:35:51 +08:00
fawney19 8a8bc999d2 fix: 链路追踪中提供商名称原样显示,不再自动去除"反代"后缀 2026-03-09 23:17:18 +08:00
AAEE86 57c7cca556 perf: 优化请求鉴权链路并批量化统计/调度查询
- 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body
- 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险
- 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环
- 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息
- 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效

- 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射
- 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复
- 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合
- 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算
- 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加

- 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询
- 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数
- 为用户日统计增加批量聚合入口,替代逐用户循环聚合
- 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询
- 为管理员用户列表增加钱包批量查询,避免逐用户回表

- 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图
- 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序
- 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描
- 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返
- 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑

- 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、
  scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
fawney19 9e6578a71b fix: 对 actual_total_cost_usd 显式转换为 float,防止类型不匹配导致累加异常 2026-03-09 21:30:51 +08:00
fawney19 bf818e3b61 fix: 钱包迁移回填 clamp 超范围值,防止 NUMERIC(20,8) 溢出 2026-03-09 20:45:13 +08:00
fawney19 9e7f291aaf Merge pull request #213 from AAEE86/123
fix: 处理客户端断开连接情况,以防止出现虚假的系统错误报告
2026-03-09 20:23:56 +08:00
fawney19andAoaoMH 46bab1b97f feat: 为下拉选择组件添加搜索过滤功能,支持拼音匹配
- 新增 search.ts 搜索工具,支持中文拼音(全拼/首字母)模糊匹配
- 新增 select-search-context.ts,通过 provide/inject 为 radix-vue Select 组件注入搜索能力
- MultiSelect、ModelMultiSelect 组件集成搜索框,选项超过阈值时自动显示
- select-content/select-item 组件支持搜索过滤与空状态提示
- StandaloneKeyFormDialog、UserFormDialog 中手写下拉框替换为复用 MultiSelect 组件
- 引入 pinyin-pro 依赖,按需懒加载

Closes #210

Co-authored-by: AoaoMH <[email protected]>
2026-03-09 19:53:54 +08:00
fawney19andAAEE86 0258d01ee6 refactor: 将 adapter 层的计费/模型抓取/行为变体能力下沉到 core.api_format 注册表
- 新增 core/api_format/capabilities.py,统一注册计费模板、模型抓取、
  total_input_context 计算和 provider behavior variant
- 新增 core/usage_tokens.py,抽取 cache token 解析逻辑到 core 层
- handler adapter 移除各自的 compute_total_input_context / fetch_models /
  BILLING_TEMPLATE 覆盖,改为委托 core 注册表解析
- provider/behavior.py 改为薄封装,底层委托 core registry
- 新增 tests/test_architecture_import_rules.py 架构导入约束测试
- 新增 tests/services/api_format/test_capabilities.py 能力注册表测试

Closes #207

Co-authored-by: AAEE86 <[email protected]>
2026-03-09 18:26:53 +08:00
fawney19 4999a1a0a8 fix: batch_alter_type 转换 NUMERIC 类型前 clamp 超范围值,防止溢出 2026-03-09 16:56:41 +08:00
fawney19 8cb8666456 fix: alembic helpers 限定 schema 查询范围,修复跨 schema 误匹配
- information_schema 查询增加 current_schema() 过滤条件
- 新增 _fk_exists() 通过 pg_constraint 直接检查外键是否存在
- replace_fk_if_needed 在缓存未命中时回退到 pg_constraint 查找
- index_exists 增加 schemaname 过滤
2026-03-09 16:46:38 +08:00
fawney19 48f3f481db fix: 号池调度 label 默认维度数改为动态计算 2026-03-09 15:44:04 +08:00
fawney19 e60462e068 fix: 号池调度 label 在无配置时错误显示为 LRU + 粘性
pool_advanced 为 null 时,poolSchedulingLabel 的 fallback 逻辑因
可选链默认值导致 lruEnabled=true、stickyEnabled=true,始终显示
"LRU + 粘性"。现在提前返回 "2 维度" 以匹配后端默认行为
(cache_affinity + recent_refresh)。
2026-03-09 15:42:32 +08:00
fawney19 c4877e3b6a fix: 号池 legacy 配置默认回退到 cache_affinity,multi_score 模式补全 LRU 数据获取
_build_from_legacy_fields 在无 legacy 字段时默认返回 cache_affinity 而非 lru,
与 PoolConfig 默认值保持一致。PoolManager 在 scheduling_mode 为 multi_score 时
也获取和更新 LRU 数据,修复 cache_affinity/single_account 维度因缺少 lru_scores
导致排序失效的问题。
2026-03-09 15:22:17 +08:00
fawney19 afbb1b9a5d perf: Redis 操作优化,移除号池 key 列表的 Usage 聚合查询
- 号池 key 列表移除 Usage 表聚合查询(total_tokens/total_cost_usd),消除慢 SQL
- cooldown 计数从 SCAN 改为 SCARD (O(1)),通过 cooldown_idx SET 维护索引
- 读路径 Lua 脚本移除 ZREMRANGEBYSCORE,清理移至写路径减少开销
- affinity 清理从 KEYS 改为 SCAN 分批删除,invalidate_all_for_provider 改用 pipeline 批量 MGET+UNLINK
- 缓存监控页添加清除按钮 loading 状态,Redis SCAN 并发限制为 4
- 提取 redis_utils 模块统一 SCAN+批量删除逻辑
- 调度热路径跳过 cooldown TTL 查询,account_state 预计算移出循环
2026-03-09 14:54:45 +08:00
fawney19 9516619b92 feat: 号池调度默认选择缓存亲和,默认开启额度刷新优先 2026-03-09 14:07:20 +08:00
fawney19 e106a65c1d perf: 在 Redis 密集操作前释放 DB 连接,candidate_records 改为异步写入
- 号池排序涉及大量 Redis I/O,在调用前提前释放 DB 连接避免连接池压力
- 新增 create_candidate_records_async,通过 asyncio.to_thread 执行同步 DB 写入
- 同步执行、异步提交、TaskService 三条路径统一改用异步版本
2026-03-09 13:53:39 +08:00
fawney19 d84c9d4b71 feat: 配置导入导出支持 ProxyNode,号池 key 可用性检查延迟到排序后分页执行
配置导入导出:
- 导出/导入新增 ProxyNode(代理节点)数据
- 导入时自动建立 old_id -> new_id 映射,重映射 Provider/Endpoint/Key 中的 node_id
- 前端预览和结果展示新增代理节点统计

号池调度优化:
- CandidateBuilder 不再逐 key 调用 _check_key_availability,直接收集全部 active key
- 将可用性检查参数打包到 PoolCandidate._deferred_check_params
- PoolManager.select_pool_keys 排序后分页调用 availability_checker,找到足够可用 key 即停止
- 减少大号池场景下不必要的可用性检查开销
2026-03-09 13:26:54 +08:00
fawney19 0046123e22 feat: Provider 摘要 API 改为服务端分页,支持搜索和筛选
- 后端 /summary 接口新增 page/page_size/search/status/api_format/model_id 参数
- 新增 ProviderSummaryPageResponse 分页响应模型
- 前端 useProviderFilters 从客户端筛选改为构建服务端查询参数
- ProviderManagement 通过 watch queryParams 实现分页/筛选联动,搜索 debounce 300ms
- PriorityManagementDialog 改为对话框打开时自行加载全量 providers
- 其他使用方(StandaloneKeyFormDialog/UserFormDialog/ReplayDialog/ModelManagement)适配新接口
2026-03-09 12:45:54 +08:00
fawney19 40736a8334 refactor: 移除迁移脚本中不必要的 backfill SQL
快照字段(username/api_key_name)已在业务层写入时填充,无需迁移时回填历史数据
2026-03-09 12:17:45 +08:00
fawney19 0a256adc94 fix: 修复迁移helper 2026-03-09 11:52:16 +08:00
fawney19 0bddc7965b perf: 同步 DB 操作迁移到 asyncio.to_thread,避免阻塞事件循环
failover/stream_telemetry/recording/stream 中的同步 DB 操作(commit/execute/query)
会阻塞 asyncio 事件循环,导致 Hub PING 心跳无法发送、worker idle timeout 断连。
将这些操作包装到 asyncio.to_thread() 中执行。

同时提取 Alembic 迁移脚本中重复的幂等性辅助函数到 alembic/helpers.py,
用批量查询缓存替代逐条 information_schema 查询,backfill SQL 合并为 LEFT JOIN。

新增 failover 中客户端断连的快速终止路径,避免继续无意义的重试。
2026-03-09 11:49:03 +08:00
AAEE86 258be3b640 fix: 处理客户端断开连接情况,以防止出现虚假的系统错误报告
捕获 starlette.requests.ClientDisconnect 异常,避免客户端主动断开连接时被当作系统未知错误(500)处理。
- 在读取请求体阶段捕获 ClientDisconnect,返回 499 状态码
- 在 adapter.handle 阶段捕获 ClientDisconnect,记录审计日志并返回 499
- 日志级别从 ERROR 降为 WARNING,减少误报告警噪音
2026-03-09 10:38:14 +08:00
fawney19 4dbfeb87b8 fix: 迁移脚本循环 2026-03-09 03:56:20 +08:00
fawney19 fa69287449 perf: 依赖数据库 CASCADE/SET NULL 替代手动清理关联表,缩短删除事务
- 批量删除移除 cleanup_key_references 手动清理,改为依赖 FK CASCADE/SET NULL
- video_tasks.key_id FK 增加 ondelete="SET NULL",附带幂等迁移脚本
- _sync_delete 增加 statement_timeout 和任务级超时保护
- 批量导入在每次 await 前释放闲置 DB 连接,按批次提交写入避免长事务
- 前端轮询改为先查后等,首次查询不再多等一个间隔
2026-03-09 03:48:20 +08:00
fawney19 654ce89541 fix: 批量删除任务完成前等待所有进度更新的异步回调完成
收集 run_coroutine_threadsafe 返回的 Future,在标记任务完成前
通过 asyncio.gather 等待所有进度更新回调执行完毕,避免任务
状态提前跳到 completed 而进度数据尚未写入 Redis 的竞态问题。
2026-03-09 02:52:38 +08:00
fawney19 fd32597015 perf: 优化批量删除策略,缩小事务粒度并增强容错
- 批量删除分批大小从 500 降至 50,减少单事务锁持有时间
- 单批失败时跳过并继续,不再中断整个删除任务
- 进度上报改为按批次计数(每 5 批或末批),替代时间限频
- 关联表清理简化为直接 DELETE WHERE IN,移除逐行分批删除
2026-03-09 02:35:07 +08:00
fawney19 84cf07b7a2 refactor: 批量删除任务状态存储从内存字典迁移到 Redis
- BatchDeleteTask 改为 BatchDeleteTaskInfo,状态序列化存入 Redis
- submit_batch_delete / get_batch_delete_task 改为 async 函数
- 删除进度通过限频回调写入 Redis,替代直接修改内存属性
- 移除内存任务注册表和过期清理逻辑,依赖 Redis TTL 自动过期
- 路由层对应调整为 await 调用
2026-03-09 02:09:24 +08:00
fawney19 e4476d0bc6 perf: Pool 批量删除改为异步任务模式,避免大批量删除阻塞请求
- 新增 batch_delete_task 模块,提交删除后立即返回 task_id,后台线程分批执行
- 新增查询任务进度的 API 端点,前端轮询展示实时进度
- RequestCandidate 大表清理改为按行数分批删除,防止单条语句超时
2026-03-09 01:35:01 +08:00
fawney19 0379f01ce8 perf: 删除 Key 前显式清理关联表,避免 CASCADE 级联删除超时
在 ProviderAPIKey 删除前,先批量删除 RequestCandidate、GeminiFileMapping、
VideoTask 等关联表记录,替代依赖数据库 CASCADE 级联删除,防止大量关联
记录导致删除操作超时。Pool 批量删除、封禁清理、Endpoint 批量删除三处
统一使用 cleanup_key_references。
2026-03-09 00:07:20 +08:00
fawney19 95e72594ea perf: Pool 账号删除后改用乐观更新替代全量重载
- PoolAccountBatchDialog: 批量删除全部成功时直接从本地列表移除,其余操作保留原重载逻辑
- PoolManagement: 单个删除后本地移除条目并更新 total,当前页为空时自动跳转前一页
2026-03-08 23:46:04 +08:00
fawney19 f9ffb1cae5 chore: bump aether-hub version to 0.1.4 2026-03-08 23:06:06 +08:00
fawney19 91b6e0a382 fix: Hub 连接清理顺序修复、OAuth 类型常量提取、disassociate 逻辑优化
- Hub proxy/worker 连接关闭时先 unregister 再延迟 abort writer,确保缓冲消息排空
- 提取 OAUTH_AUTH_TYPES 常量,替代各处硬编码的 OAuth 类型列表
- auto-disassociate 跳过 OAuth Key,避免其动态 allowed_models 干扰判定
- 删除 Key 时传入 skip_disassociate=True,跳过不必要的解关联检查
- ModelMapper 缓存命中时将 ORM 实例脱离 Session,修复 DetachedInstanceError
2026-03-08 23:03:56 +08:00
fawney19 f5f7a23bb0 fix: 错误响应读取移至连接关闭前,ModelMapper 缓存改为模块级共享
1. chat_handler_base/cli_stream_mixin: 将 _extract_error_text 提前到
   response_ctx.__aexit__ 之前执行,避免连接关闭后无法读取错误响应体
2. ModelMapperMiddleware: 实例级缓存改为模块级共享缓存,消除多实例
   间缓存不一致问题;缓存失效服务改为直接调用静态方法
2026-03-08 22:18:10 +08:00
fawney19 8be9601963 fix: recording.py 中所有 float() 改为 Decimal(),修复与数据库 Numeric 字段相加的类型错误 2026-03-08 21:58:55 +08:00
fawney19 eaad1579e6 fix: dashboard 统计字段补充 float() 转换,修复 Decimal 与 float 混合运算的类型错误 2026-03-08 21:54:20 +08:00
fawney19 1d8bf56efd fix: defer() 改为链式调用,修复 SQLAlchemy 多参数报错 2026-03-08 21:40:36 +08:00
fawney19 73db997e92 fix: 统计聚合中成本字段 float 改 Decimal,修复与数据库 Numeric
字段相加的类型错误
2026-03-08 21:33:05 +08:00
fawney19 2c5654d694 fix: 单个迁移文件自行提交 2026-03-08 18:53:36 +08:00
fawney19 f490c3a5cd fix: 优化迁移脚本效率 2026-03-08 18:42:27 +08:00
fawney19 2d9158b321 fix: 把同一张表的多个列放在一条 ALTER TABLE 里 2026-03-08 17:56:03 +08:00
fawney19 48d13762d9 refactor(cost,perf): 成本字段 Float 改 Numeric 并优化多处查询性能
- 数据库所有 cost/price 字段从 Float 改为 Numeric(20,8),解决浮点精度问题
- API 响应中 Decimal 值统一用 float() 转换确保 JSON 序列化
- 路由中手动 commit 后标记 tx_committed_by_route 防止中间件重复提交
- 多处查询优化:SQL 聚合替代 Python 遍历、load_only/defer 减少字段加载、
  批量 DELETE 替代逐条 ORM 删除、N+1 查询消除、UNION ALL 合并多表日期查询
- 新增 provider_api_keys (provider_id, is_active) 复合索引
- 候选构建热路径 defer 冷字段,钱包扣费合并解析与加锁查询
2026-03-08 16:44:16 +08:00
fawney19 bd3f73c2fc feat(retention): 删除用户/Key 时保留历史记录,外键改 SET NULL 并添加名称快照
- Usage/RequestCandidate/VideoTask/Stats 等表的 user_id/api_key_id 外键从
  CASCADE 改为 SET NULL,删除用户或 Key 后历史记录不再丢失
- 各表添加 username/api_key_name 快照字段,删除后仍可追溯归属
- 新增 bulk_cleanup 模块,分批置空大表外键避免长事务锁
- 删除用户/Key 流程集成预清理步骤,先置空再删除
- 精简 candidate_builder 冗余 debug 日志
- 修复 proxy_nodes 启动日志 format 占位符错误({} -> %s)
- 前端批量操作请求增加 5 分钟超时配置
2026-03-08 14:31:15 +08:00
fawney19 25c33846be perf(pool): 添加性能计时日志,优化批量删除分批与模型解除关联查询
- 前端批量操作对话框添加 loadAllKeys/executeAction 计时日志
- 后端池账号列表接口和批量删除接口添加分阶段耗时日志
- 批量删除按数据库类型自动选择分批大小,统一前端 batch size 为 2000
- 优化 auto_disassociate 查询:先检查 unlimited key 提前返回,使用 load_only 减少字段加载
- 新增批量操作路由和自动解除关联的单元测试
2026-03-08 03:58:10 +08:00
fawney19 124c4ca403 fix(pool): 优化批量删除性能,使用 SQL 批量删除替代逐条 ORM 删除
- 后端批量删除改用 sa_delete 直接执行 SQL,避免逐条加载和删除
- 前端删除操作批次大小从 2000 减小到 50,防止大批量删除超时
- 增加前端批量操作失败时的错误日志输出
2026-03-08 03:08:27 +08:00
fawney19 ef0f8dd4d0 feat(pool,provider_ops): 扩展批量操作支持代理设置,优化签到缓存与余额刷新
- pool batch-action 新增 clear_proxy/set_proxy 操作,前端统一使用
  batch-action API 替代逐个调用,批量上限从 100 提升至 2000
- batch-action 删除操作后执行 key 删除副作用(run_delete_key_side_effects)
- BalanceAction 签到增加 6 小时缓存冷却,同一 host 避免重复签到
- 异步余额刷新增加 per-provider 防重入保护
2026-03-08 02:29:51 +08:00
fawney19 d0eca509d4 fix(http): 支持默认HTTP客户端原子重建以恢复HTTP/2流容量
feat(db): 为外键列补充缺失的数据库索引并添加迁移脚本

- HTTPClientPool._reset_default_client 原子替换共享客户端,旧客户端在宽限期后异步关闭
- reset_upstream_client 对无代理路由不再直接跳过,改为调用 _reset_default_client
- 为 Usage, WalletTransaction, PaymentCallback, RefundRequest, VideoTask, RequestCandidate 等表的外键列添加 index=True
2026-03-08 01:27:50 +08:00
fawney19 90663793a2 Merge branch 'feat/wallet-system' into master
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

Closes #204
2026-03-08 00:06:05 +08:00
LewisPen 783f654953 feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
2026-03-08 00:05:48 +08:00
fawney19 9cdcce1b5f fix(providers): 允许 max_probe_interval_minutes 设为 0 并修复零值被覆盖的问题
- 将 max_probe_interval_minutes 校验范围从 2-32 改为 0-32
- 修复 routes.py 中 max_probe_interval_minutes 和 cache_ttl_minutes
  使用 `or` 短路导致零值被默认值覆盖的问题,改用 is not None 判断
- 前端表单同步调整最小值约束和提示文案
- 新增零值校验的单元测试
2026-03-07 18:43:28 +08:00
fawney19 06b483f79d fix(task): 修复 SyncTaskExecutionService 调用 dispatch 缺少 user_id 参数及返回值解包数量不匹配 2026-03-07 17:32:17 +08:00
fawney19 a00e137ffc fix(providers): 修复 Key 表单状态同步与 streaming 候选状态码处理
前端:
- KeyAllowedModelsEditDialog 同时监听 open 和 apiKey 变化,避免切换 key 时状态未刷新
- KeyFormDialog 新增 api_formats 过滤与默认值逻辑,可用格式变化时自动同步表单
- ProviderDetailDrawer 合并 provider 和 endpoint 的 api_formats 传递给 Key 表单,
  数据刷新后同步 currentEndpoint 和 editingKey 引用

后端:
- mark_candidate_streaming 移除 status_code 参数,streaming 阶段不再提前写入状态码
- 简化 active_requests 中 streaming 请求的完成判断,不再依赖 status_code 条件
2026-03-07 17:11:19 +08:00
fawney19andAAEE86 239238fe47 refactor(task): 拆分 TaskService 并重构任务生命周期
- 将 task 公共协议/上下文/异常/schema 下沉到 core,并迁移 polling 目录
- 新增 execute/submit/video 子模块,拆分同步执行、异步提交流程、错误处理与视频任务操作
- 收敛 TaskService 为门面编排,内部委派到 SyncTaskExecutionService、AsyncTaskSubmitService、VideoTaskOperationsService
- 重构 main 生命周期管理:引入 LifecycleState,拆分启动与关闭流程
- 删除未使用的 TaskExecuteFacadeService 与 TaskSubmitFacadeService

Closes #201

Co-authored-by: AAEE86 <[email protected]>
2026-03-07 15:33:29 +08:00
github-actions[bot] 4cd6e0d10f chore(proxy): update download links for proxy-v0.2.4 2026-03-06 20:06:59 +00:00
fawney19 9b29a65c68 chore: bump aether-hub version to 0.1.3 2026-03-07 04:02:02 +08:00
fawney19 df4a49a9fb chore: bump aether-proxy version to 0.2.4 2026-03-07 04:00:27 +08:00
fawney19 bb268310e2 feat(keys): 新增 Provider Keys 批量删除 API
- 后端新增 POST /keys/batch-delete 接口,支持一次删除最多 100 个 Key
- 按 provider_id 聚合执行副作用,避免逐个删除导致的重复 Redis 操作
- 前端批量操作对话框中删除操作改用批量 API,按 100 个一批分批调用
2026-03-07 03:49:44 +08:00
fawney19 7b0908cd87 fix(task): TaskPoller Redis 分布式锁操作增加异常捕获,避免 Redis 故障时任务轮询崩溃 2026-03-07 03:14:48 +08:00
fawney19 95bc742057 fix(gunicorn): worker timeout 从 120s 放宽到 300s,收敛到配置文件统一管理
- 移除 Dockerfile 命令行中硬编码的 --timeout 120,由 gunicorn_conf.py 统一管理
- timeout 默认值从 120 调整为 300,减少异步 worker 偶发心跳延迟导致的误杀
- timeout 和 graceful_timeout 均支持环境变量覆盖(GUNICORN_TIMEOUT / GUNICORN_GRACEFUL_TIMEOUT)
2026-03-07 03:13:44 +08:00
fawney19 e40c890a8e fix(usage): 超时请求清理增强,支持恢复已成功的 streaming 请求
重构 pending/streaming 请求清理逻辑:
- 提取 _find_completed_request_ids 和 _sync_candidate_status_to_success 公用方法
- 清理超时请求时检查 RequestCandidate,已成功的恢复为 completed 而非标记 failed
- 同步更新 candidate 状态,保持 Usage 与 RequestCandidate 一致
- 启动时主动执行一次 pending 清理
2026-03-07 03:04:09 +08:00
fawney19 357c4fd61f fix(build): 构建时 GitHub API 请求支持可选 GITHUB_TOKEN 避免限流
未认证 GitHub API 限流 60 次/小时/IP,频繁本地构建容易触发。
添加可选的 GITHUB_TOKEN 支持(认证后 5000 次/小时),不传 token 时行为不变。
2026-03-07 03:03:55 +08:00
fawney19 d28fea80df fix(trace): 请求追踪详情默认展示全部候选记录 2026-03-07 02:30:48 +08:00
fawney19 fb1aeb789a feat(test,quota,failover): 模型并发测试、统一配额读取器与故障转移取消支持
- 新增 QuotaReader 抽象层,统一 Codex/Kiro/Antigravity 配额解析逻辑,
  替换 pool/routes.py 中分散的配额构建函数
- 模型测试支持并发执行多候选,前端新增 useModelTest composable 统一
  ModelsTab 和 ModelMappingTab 的测试逻辑
- ModelTestDialog 增加结果概览摘要、超长结果折叠、端点列和新状态支持,
  删除已合并的 TestResultDialog
- FailoverEngine 新增客户端断开检测,支持取消剩余候选并标记记录
- 刷新配额改为分批执行,直连测试候选按可用性排序
- 修复 error 判断从 "error" in dict 改为 dict.get("error") 避免误判
2026-03-07 02:16:03 +08:00
fawney19 1f3693d3a2 fix(migration): body_rules 回填限定 codex 提供商类型
原迁移仅按 api_format 过滤,会误回填所有 openai:cli 端点;
现 JOIN providers 表增加 provider_type = 'codex' 条件。
2026-03-06 23:44:37 +08:00
fawney19 90760da499 feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留
- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时
- 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条)
- 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段
- SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token
- 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改)
- Codex envelope 移除合成头部,保留客户端原始请求头
- endpoint_checker 支持自定义超时透传
- 新增 x-forwarded-scheme 到上游丢弃头部列表
2026-03-06 21:06:45 +08:00
fawney19 7950ba7dc5 fix(usage): 请求详情默认选中轻量 tab,避免自动加载大 body
首次打开请求详情时优先选择 request-headers / response-headers / metadata
等轻量 tab,而非直接落到 body tab 触发按需加载。
2026-03-06 18:19:57 +08:00
fawney19 a7088ee538 fix: 修复 http缺少cls 2026-03-06 18:01:28 +08:00
fawney19 050cba9563 feat(proxy,failover,transport): Hyper 上游客户端精细计时、连续失败退避与连接泄漏修复
Proxy:
- 将上游 HTTP 客户端从 reqwest 替换为 hyper,新增 InstrumentedConnector
  实现 TCP 连接/TLS 握手级别的独立计时,上报 connection_reused 等指标
- 前端展示细粒度代理计时(连接复用、等待响应头等)

Failover:
- 引入连续失败退避机制,每 10 次失败递增退避间隔
- 检测 H2 max outbound streams 错误并触发上游客户端重建
- 新增 HTTPClientPool.reset_upstream_client 支持按需重建缓存客户端

连接泄漏修复:
- Handler 异常路径确保 response_ctx 被正确关闭
- HubResponseStream 迭代结束后在 finally 块中清理 stream_id
- HubTunnelTransport.handle_request 捕获所有异常并清理流状态
2026-03-06 17:55:14 +08:00
fawney19 2269617a9f fix(nginx): 剥离 Cloudflare 请求头,防止泄露给上游 AI 提供商
在反向代理配置中将 CF-Connecting-IP、CF-IPCountry、CF-Ray、
CF-Visitor、CDN-Loop、True-Client-IP、CF-Worker、CF-EW-Via
等头置空,避免用户真实 IP 及 CF 元数据被透传到上游。
2026-03-06 15:54:34 +08:00
fawney19 bdccfa6e78 feat(usage,pool,codex): 请求详情 body 按需加载、配额选择器重构与 Codex body rules 修正
- usage 详情 API 新增 include_bodies 参数,支持跳过 body 内容返回 has_*_body 标记
- 前端请求详情抽屉首次加载不含 body,切换到 body tab 时延迟加载并展示 skeleton
- Timeline 组件延迟 120ms 挂载,避免阻塞抽屉渲染
- 提取号池配额判断逻辑到 quota-selectors 工具模块并添加单测
- Codex 移除 openai:compact 的默认 body rules 注册
- ModelTestDialog/TestResultDialog 模板格式化
2026-03-06 15:40:11 +08:00
fawney19 d97ec3fde2 feat(admin,pool,billing): 端点级模型测试、Provider 自动置顶、缓存 TTL 分级计费展示与账号状态增强
- 模型测试支持指定端点:新增 ModelTestDialog 组件,多端点时弹窗选择,单端点直接测试;
  后端 test-model-failover 接口新增 endpoint_id 参数,支持 global/direct 模式下按端点过滤候选
- 创建 Provider 时优先级自动置顶(provider_priority 默认 None,后端取 min-1),
  显式指定优先级时 shift 已有行;前端创建时不发送 priority,更新时保留
- 缓存计费 UI 增强:RequestDetailDrawer 支持 5min/1h 缓存创建 token 分级展示,
  含按 TTL 匹配单价和分行成本计算;ModelDetailDrawer/ModelsTab 标签区分 5min/1h 缓存创建
- Pool 批量操作额度筛选拆分为「无5H限额」和「无周限额」,按 | 分隔 segment 匹配
- KeyFormDialog 优化非 vertex_ai 时布局,API 密钥输入内联到 grid 右列
- Codex refresher 结构化错误标记:401/402/403 使用 [OAUTH_EXPIRED]/[ACCOUNT_BLOCK] 前缀,
  新增 deactivated_workspace 识别与分类
- 前后端 accountBlock 关键词同步:新增 token invalidated、deactivated_workspace 识别,
  OAuth 失效提示清理 block 前缀后展示
- PoolConfig 新增 batch_concurrency 配置(默认 8,上限 32)
- 预设模型新增 gpt-5.4;TestResultDialog 响应式布局与 key 脱敏优化
2026-03-06 13:13:01 +08:00
github-actions[bot] d17472f09e chore(proxy): update download links for proxy-v0.2.3 2026-03-05 18:01:47 +00:00
fawney19 f8b7cd2925 chore: bump aether-proxy version to 0.2.3 2026-03-06 01:48:50 +08:00
fawney19 e9c3ac94c6 feat(proxy,oauth,pool): H2 头过滤、OAuth 过期分级标记与批量操作进度条
- proxy: 屏蔽 host/content-length 头转发,避免 H2 PROTOCOL_ERROR
- oauth: 区分 [REFRESH_FAILED] 与 [OAUTH_EXPIRED] 标记,token 过期
  自动阻止调度但不停用账号,便于管理员恢复
- pool/account_state: 识别新增的 OAUTH_EXPIRED/REFRESH_FAILED 前缀
- 前端: 批量操作显示实时进度条,倍率编辑 Escape/blur 竞态修复,
  OAuth 刷新失败后自动刷新列表
2026-03-06 01:47:09 +08:00
fawney19 fa71cddb60 feat(proxy,billing,pool): 增强隧道/流中断诊断日志、修复缓存 TTL 差异化计价
- stream_processor: 上游流中断时记录完整异常链与已传输 token 统计
- hub_transport: 断连影响 in-flight 流、STREAM_ERROR、超时场景补充 warning 日志;
  未启用时跳过重连循环,重连失败日志降频避免刷屏
- tunnel_manager: 流超时/错误/全部取消/STREAM_ERROR 增加诊断日志与字节统计
- billing_integration: 计费时自动补全 cache_ttl_minutes(从 provider key 查询
  或从 5m/1h 细分 token 回推),修复缓存 TTL 差异化计价缺失
- PoolManagement.vue: 移除重复的账号告警 Badge
- 新增 billing integration 单元测试
2026-03-06 00:14:10 +08:00
fawney19 228cbc8f87 feat(pool,admin): 拆分调度维度、重构调度 UI 与增强配置导入导出
调度维度:
- 新增 cache_affinity/free_first/team_first/plus_first/load_balance 五个独立维度
- 将 free_team_first 标记为 hidden,保留后向兼容但不再显示
- Registry 新增 hidden 属性,_helpers 新增 plus_only 优先级评分

前端调度对话框:
- 分配模式(互斥组)独立为按钮组选择,策略调度保留拖拽排序
- 默认调度从 LRU 轮转改为缓存亲和
- 提取 buildPresetListItem/insertMissingByPreferredOrder 消除重复代码

配置导入导出:
- 导出时 api_formats 支持规范化、去重与 None 回退到 Provider 端点
- 导入时兼容 supported_endpoints 别名与历史 None 语义
- 新增 test_admin_system_key_formats 单元测试
2026-03-05 23:22:25 +08:00
fawney19 da915208a8 feat(provider,adapter): Codex 默认 body_rules 按 provider_type 维度注册,adapter 全链路传递 provider_type
- 新增 register_provider_default_body_rules 注册机制,将 Codex 特有的
  body_rules 从 EndpointDefinition 全局默认移至 codex plugin 按
  (provider_type, endpoint_sig) 维度注册
- handler adapter 的 build_endpoint_url/build_request_body/get_cli_extra_headers
  增加 provider_type 参数,Codex 判断优先使用 provider_type 而非 URL 匹配
- 前端 getDefaultBodyRules API 支持 provider_type 参数,缓存 key 区分不同
  provider 类型;Codex 路径判断同样优先使用 provider_type
- ProviderDetailDrawer 将 mapping-preview 拆为独立加载,不阻塞首屏渲染
- PoolManagement 补全 KeyFormDialog 缺失的 endpoint/available-api-formats props
- 固定类型 Provider 创建时自动填充 provider-scoped 默认 body_rules
2026-03-05 19:04:21 +08:00
fawney19 694167f78f feat(pool,trace): 账号封禁原因细分与请求追踪 attempted_only 过滤
- 将账号封禁原因从笼统的"账号异常"细分为封禁/停用/需要验证三类,
  前后端关键词组同步拆分,号池管理页面展示对应分类标签
- trace API 新增 attempted_only 参数,支持仅返回实际尝试过的候选,
  前端时间线组件默认启用过滤,排除 available/unused/skipped 记录
2026-03-05 17:32:21 +08:00
fawney19 a7697032a4 feat(pool): 账号停用检测、OAuth refresh 失效标记与号池管理性能优化
- 新增 account_deactivated 关键词检测,覆盖 error_handler / health_policy / account_state / 前端
- 401 健康策略分级冷却:账号永久停用 1h,临时认证失败 60s
- OAuth refresh token 失败时标记 oauth_invalid(不停用 key),成功时清除非账号级标记
- 号池管理 API 使用 load_only + SQL 聚合替代全量拉取,减少查询开销
- Redis 冷却统计改用 batch_count_provider_cooldowns (SCAN) 替代逐 key 检查
- 前端时间线移除 executableTimeline 过滤层,Provider 选择改为非阻塞加载
2026-03-05 16:23:58 +08:00
fawney19 e86c8edd4b feat(provider): 新增 keep_priority_on_conversion 字段,支持格式转换时保持调度优先级 2026-03-05 15:28:03 +08:00
fawney19 b1be413dc0 feat(pool): 号池额度主动探测、封禁自动清除、调度硬优先级与前端重构
- 新增 PoolQuotaProbeScheduler,按 probing_interval_minutes 主动探测静默 Key 额度
- pool_advanced 增加 probing_enabled / auto_remove_banned_keys 配置项
- error_handler 和 quota_service 支持封禁 Key 自动删除及缓存清理
- multi_score 策略从加权混合重构为硬优先级排序,引入 mutex_group 互斥组
- 指纹注入从 handler 层下移至 ClaudeCode envelope 层
- OAuth 批量导入支持 concurrency 并发参数
- 前端号池管理拆分高级设置/账号批量/代理设置为独立组件
- 号池总览接口精简,仅返回已启用调度的 Provider
2026-03-05 15:15:26 +08:00
fawney19 fdb50a065b refactor(fingerprint): 移除请求头构建路径中的 per-key 指纹定制化注入
headers.py 删除 build_browser_fingerprint_headers / build_anthropic_extra_headers
函数及相关辅助,改为直接使用固定常量;PassthroughRequestBuilder 移除
Claude 格式的指纹注入步骤;Antigravity constants 移除 per-key 指纹
覆盖逻辑,统一使用进程级固定值。
2026-03-05 10:24:52 +08:00
fawney19 1ac59d4894 feat(pool): 新增调度维度、互斥组机制、健康策略扩展与配额刷新增强
- 新增 priority_first/health_first/latency_first/cost_first 四个调度维度
- 引入 mutex_group 互斥组机制,lru 与 single_account 归入 distribution_mode
- 维度 compute_metric 签名扩展 context 参数,支持获取 cost_totals 等上下文
- 各维度增加 evidence_hint 字段描述评分依据
- 健康策略扩展 408/409/423/425/5xx 瞬态状态码冷却,403 按 body 分级冷却
- Codex 配额刷新增强 401/402/403 错误处理,402 生成 fallback 元数据
- 前端号池管理支持账号优先级内联编辑与互斥维度切换 UI
- 列表排序改为 internal_priority + created_at,移除 sticky_counts 查询
2026-03-05 09:53:23 +08:00
fawney19 32ccf61baa feat(fingerprint): 引入 per-key 请求指纹系统,替代全局 TLS 指纹开关
为每个 ProviderAPIKey 生成并持久化独立的请求指纹配置,涵盖 TLS impersonate
profile、浏览器 UA、Stainless SDK 头部、Node/Chrome/Electron 版本等维度。
指纹基于 key ID 确定性生成,支持手动编辑和批量重新生成。

- 新增 fingerprint 模块:生成、加载、校验、懒持久化
- 数据库迁移:provider_api_keys 新增 fingerprint JSON 列
- 请求链路注入:handler 基类设置上下文指纹,request_builder 和 envelope 消费
- HTTP Client 支持动态 impersonate profile 选择
- Antigravity 适配器使用指纹覆盖 UA/session/Node 版本
- 前端移除手动 TLS 指纹开关,新增批量 regenerate_fingerprint 操作
- 号池管理 UI 优化:token 缩写格式、blocked 行样式、时间显示改为日期格式
2026-03-05 02:07:34 +08:00
fawney19 1d04c41ae7 refactor(pool): 移除调度多维评分详情,简化为 reasons 摘要
移除 scheduling_score、candidate_eligible、scheduling_dimensions 等字段,
前后端统一使用 scheduling_reasons 展示调度状态,精简号池列表信息密度。
2026-03-04 22:51:02 +08:00
fawney19 b2dcf82ca8 feat(pool): 引入多维评分调度策略与账号状态检测
- 新增 multi_score 调度模式,支持 LRU/延迟/健康度/剩余额度多维加权评分
- 新增调度预设维度系统(free_team_first, quota_balanced, recent_refresh, single_account),支持有序对象列表配置格式并兼容旧字符串列表
- 新增 account_state 模块,统一账号封禁/受限检测逻辑,替代分散在 routes 中的判断代码
- 新增 health_cache 模块和 latency 采样(redis_ops.record_latency / batch_get_latency_avgs)
- RequestDispatcher 返回 ttfb_ms,PoolManager.on_request_success 记录延迟样本
- 前端:PoolConfigDialog 替换为 PoolSchedulingDialog,支持预设维度可视化配置;号池管理页增加调度模式标签与账号异常 Badge 显示
- 提取前端 accountBlock 工具函数,ProviderDetailDrawer 复用统一判断
- scheduling_dimensions 增加 account_state 和 latency 维度评估
- 补充 account_state、health_cache、multi_score 策略、preset 维度、redis latency 等测试
2026-03-04 22:06:19 +08:00
fawney19 57b86034cf feat(pool,priority): 号池聚合显示、API 格式归一化与优先级管理重构
- 优先级管理对话框按 family 分组显示 API 格式,号池 key 聚合为单条目展示
- 拖拽排序改用 key ID 替代数组索引,号池聚合项禁用拖拽/编辑/开关操作
- 后端 key 分组查询增加 API 格式键归一化,返回 provider_id
- 提取 OAuth auth_config 解密逻辑,新增 _derive_oauth_expires_at 从加密配置派生过期时间
- 号池管理移除会话列,调整 OAuth 过期信息与刷新按钮的布局顺序
2026-03-04 12:59:27 +08:00
fawney19 095e312ab3 fix(usage): 修正请求时间线 unused 候选过滤与分组排序逻辑
- 号池内 unused key 不再展示,非号池 unused 仅保留 retry_index=0
- buildProviderGroups 使用 candidate_index 替代数组下标作为分组索引
- 号池组与供应商组合并后统一按 startIndex 排序
2026-03-04 10:26:19 +08:00
fawney19 82a9fb3c39 feat(codex): 增强 OAuth 导入解析与账号信息提取,优化维护调度器线程模型
Codex OAuth:
- 导入解析支持附加账号字段(account_id/plan_type/user_id/email)
- enrich_codex 扩展从 access_token 和直接字段提取账号信息
- parse_codex_id_token 支持 JWT/JSON 字符串/dict 三种输入格式
- request patching 新增 openai:compact 格式支持
- codex_usage_parser 新增 credits_unlimited 字段解析

维护调度器:
- 同步 DB 操作迁移到线程池执行,避免阻塞事件循环
- 新增 request_candidates 定期清理任务
- 新增每周 VACUUM ANALYZE 数据库表维护任务
- 新增 enable_db_maintenance 配置项

前端:
- ElapsedTimeText 从 setInterval 改为 requestAnimationFrame
- 时间线过滤 available/unused 占位记录
- Usage 页面默认关闭全局自动刷新
- 移除号池管理中的配额更新时间显示
2026-03-04 10:00:38 +08:00
fawney19 e181329a81 fix(usage): 抽取 ElapsedTimeText 组件、缩短缓存 TTL 提升实时性,修复 _KeyCandidate slots
- 将 UsageRecordsTable 中的内联计时逻辑抽取为独立的 ElapsedTimeText 组件
- usage records 缓存 TTL 从 15s 降为 3s,全局自动刷新间隔从 5s 改为 3s 并默认开启
- 补充 PoolManager._KeyCandidate 缺失的 __slots__ 字段
2026-03-04 02:24:46 +08:00
fawney19 cdce817928 fix(announcement): 将分页计数移至排序之前执行 2026-03-03 22:35:08 +08:00
fawney19 97b0146ce9 perf: 全栈查询优化、前端缓存去重与页面可见性优化
后端:
- SQL count 查询统一改用 func.count() 子查询替代 query.count()
- Dashboard/Audit 等页面多次独立查询合并为单次聚合查询
- Provider summary 列表改为批量查询消除 N+1 问题
- DailyStats 逐天循环查询改为 CASE 分桶单次查询
- 使用 load_only() 减少不必要的列加载
- cache_decorator 支持嵌套属性路径解析(dotted vary_by)
- 多个管理/公共端点新增 @cache_result 缓存装饰

前端:
- cache.ts 新增 in-flight 请求复用、dedupedRequest、buildCacheKey
- 大量 API 调用添加前端缓存或去重
- 多个页面定时器在标签页隐藏时暂停、可见时恢复
- Auth 检查从 setInterval 改为 storage + visibilitychange 事件驱动
- 请求竞态防护(requestId 模式)

数据库:
- Usage 表新增 idx_usage_status_user_created 复合索引
2026-03-03 22:04:40 +08:00
fawney19 0a60492146 fix(proxy_nodes): 更新代理节点模块描述 2026-03-03 17:28:56 +08:00
fawney19 4ea187cfac feat(pool): 号池候选重构为 PoolCandidate 单候选模式与池内 key 故障转移
- 新增 PoolCandidate 子类,排序阶段作为单候选参与,执行阶段在 pool_keys 内部选择/切换 key
- FailoverEngine 新增 _execute_pool_candidate 方法,支持池内 key 级别故障转移与重试
- 提取 _execute_attempt / _attach_attempt_context / _classify_attempt_error 公共方法
- CandidateBuilder 对号池 Provider 构建单个 PoolCandidate(包含所有可用 key)
- CandidateSorter 支持 PoolCandidate 独立优先级分组(global_priority / pool_priority)
- CandidateResolver PRE_EXPAND 模式按 pool_keys 展开预创建记录,附加 pool_group_id
- TaskService._apply_pool_reorder 改为对 PoolCandidate 调用 select_pool_keys
- PoolManager 新增 select_pool_keys 方法,复用 reorder_candidates 逻辑
- 新增 global_priority 号池配置字段(前后端同步)
- 前端 Timeline 支持按 pool_group_id 分组显示多号池尝试
- 异步提交路径新增 _expand_pool_candidates_for_async_submit 展开逻辑
2026-03-03 17:24:22 +08:00
fawney19 dcba7c62a2 docs: 更新 README 部署说明与默认配置
- 调整 Docker Compose 部署方式的标题描述
- 恢复预构建镜像部署的正常命令流程
- 移除 deploy.sh 的 --hub-tag 选项说明
- Aether Proxy 标注为可选组件
- GUNICORN_WORKERS 默认值调整为 2
2026-03-03 16:38:22 +08:00
fawney19 dba99455a7 fix: 移除redis持久化功能 2026-03-03 12:44:48 +08:00
fawney19 7ebce161e8 feat(pool,ui,trace): 号池按页配额刷新、配额倒计时、Trace 全量候选与 UI 用语统一
- 号池管理支持按当前页 Key 刷新配额,后端 refresh-quota 接口支持 key_ids 参数筛选
- 配额进度条 tooltip 展示重置倒计时,前端解析后端重置时间并实时倒计时
- Provider 选择器在无号池提供商时禁用,切换/刷新后保持选中状态对齐
- 启停账号后立即更新调度标签并刷新列表
- Trace 监控展示全量候选记录,不再过滤 available/unused 状态
- 请求时间线号池节点与 Provider 节点去重
- 全局 UI 用语统一:已停用/已禁用 -> 停用/禁用
- 导航菜单调整模型管理与号池管理顺序
2026-03-03 11:32:41 +08:00
fawney19 022aec5720 fix: 修复迁移版本号重复问题 2026-03-03 09:28:33 +08:00
fawney19andAAEE86 11997c024e feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构
- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康)
- 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因
- Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息)
- 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key
- Handler 基类增加请求调度元数据采集,stream telemetry 增强
- 请求时间线组件增强,支持 attempted 候选展示
- Kiro OAuth 凭证导入解析改进
- 新增 usage 表 provider_key 索引迁移
- 补充调度维度、配额冷却、候选枚举等单元测试

Closes #197

Co-authored-by: AAEE86 <[email protected]>
2026-03-03 09:22:20 +08:00
fawney19 f787b1b02a fix(codex): 取消对 openai:compact 端点的 body_rules 修改
Codex 的请求体规则(drop max_output_tokens/temperature/top_p, set store=false 等)
只应作用于 openai:cli, 不应影响 openai:compact 端点。
2026-03-03 03:39:14 +08:00
fawney19 a03368a3fe fix(usage): metadata tab 也显示展开/收缩和复制按钮 2026-03-02 22:44:49 +08:00
fawney19 e26ed8481f fix(scheduling): 负载均衡模式与无亲和键场景统一走随机排序
重构 CandidateSorter.shuffle_keys_by_internal_priority 中同优先级
Key 的排序逻辑,将三分支简化为两分支:
- 随机排序:TTL=0 / 负载均衡模式 / 无 affinity_key
- 哈希确定性排序:缓存亲和模式且有 affinity_key

移除了原先"无 affinity_key 时按 ID 排序"的冗余分支,新增
对应单元测试覆盖三种场景。
2026-03-02 22:36:24 +08:00
fawney19 8e98eed5c8 refactor(failover): 用 provider failover_rules 替代硬编码 ErrorClassifier 判断
移除 submit_with_failover 中基于 ErrorClassifier 的客户端错误硬编码逻辑,
改为读取 provider.config.failover_rules 进行规则匹配:
- error_stop_patterns: 错误响应命中时终止 failover
- success_failover_patterns: 2xx 响应命中时继续尝试下一个候选
同步更新相关注释、异常描述及测试用例
2026-03-02 22:21:13 +08:00
fawney19 0bff15f964 feat(endpoint): 端点默认 body_rules 机制与 Codex 规则回填
- EndpointDefinition 新增 default_body_rules 字段,openai:cli/compact 配置 Codex 默认规则
- 创建端点时若未指定 body_rules 则自动填充对应格式的默认值
- 新增 GET /defaults/{api_format}/body-rules 接口查询默认规则
- 前端 EndpointFormDialog 增加"重置请求体"按钮,支持一键恢复默认
- Alembic 迁移回填已有 Codex 端点的默认 body_rules
- 新增 metadata 和 endpoint 创建默认值的单元测试
2026-03-02 22:08:39 +08:00
fawney19 3384c6d666 fix(alembic,pipeline,resilience): 数据库迁移与异常处理健壮性修复
- alembic env: 改用事务级 advisory lock (pg_advisory_xact_lock),事务结束自动释放
- 迁移脚本: 使用原生 SQL IF NOT EXISTS/IF EXISTS 替代运行时列检查
- pipeline: SQLAlchemy 异常后先回滚事务再写审计,防止 aborted 状态二次报错
- resilience: ProgrammingError 标记为不可恢复,缩减 DB 重试异常范围
- 前端: 端点规则支持拖拽排序
2026-03-02 18:02:21 +08:00
fawney19 5f1c74aca0 fix(cli): 统一 CLI handler pending usage 的 api_format 来源
CLI stream/sync mixin 中创建 pending usage 记录时,api_format 取值
来源不一致(部分用 FORMAT_ID,部分用 allowed_api_formats[0])。
新增 primary_api_format 属性并统一使用,确保 pending 记录的格式
与实际客户端请求格式一致。
2026-03-02 15:13:28 +08:00
fawney19 310355bcc1 fix(ci): 修复多架构Docker镜像构建,arm64覆盖amd64问题
分开push到同一tag会导致后者覆盖前者manifest。
改为先按digest分别push,再用imagetools合并为多架构manifest。
2026-03-02 13:59:25 +08:00
fawney19 ab07d83aaf fix(ci): 使用认证的gh CLI替代未认证curl调用GitHub API
download-hub步骤使用未认证的curl获取release列表,
频繁触发GitHub API速率限制导致构建失败。
改用gh CLI自带GITHUB_TOKEN认证避免此问题。
2026-03-02 13:41:05 +08:00
github-actions[bot] 11cdf52f7b chore(proxy): update download links for proxy-v0.2.2 2026-03-02 05:37:31 +00:00
fawney19 8a2c3596ce chore: bump aether-hub version to 0.1.2 2026-03-02 13:29:27 +08:00
fawney19 a61d16d120 chore: bump aether-proxy version to 0.2.2 2026-03-02 13:29:15 +08:00
fawney19 01df063cc1 feat(ci,alembic): Hub Docker镜像构建发布,数据库迁移并发安全加固
- build-hub.yml 新增 Docker job,构建多架构镜像推送至 GHCR 和 Docker Hub
- build-hub/build-proxy Release 名称简化为 tag 名
- alembic/env.py 使用 PostgreSQL advisory lock 防止多进程并发迁移竞态
- 迁移脚本改用 ADD/DROP COLUMN IF NOT EXISTS 替代 inspector 检查
2026-03-02 13:24:58 +08:00
fawney19 68bae686da feat(proxy): 支持远程推送升级与proxy元数据上报
- aether-proxy 注册和心跳时上报 proxy_metadata(含版本号)
- 心跳 ACK 支持 upgrade_to 字段,proxy 收到后自动执行升级
- 重构 upgrade 逻辑,新增 perform_upgrade 用于远程触发的自动升级
- stream_handler 延迟统计改为仅记录连接建立延迟(DNS+TCP/TLS+TTFB)
- 后端新增 proxy_metadata 数据库字段和批量升级 API
- 远程配置支持下发 upgrade_to 版本指令
- 前端展示节点版本号,支持单节点和批量升级操作
2026-03-02 12:58:41 +08:00
fawney19 f978888759 feat(heartbeat): 心跳可靠性增强,原子计数与去重优化
- Rust proxy: 引入 snapshot+ACK 确认机制,心跳未确认时保留快照重发,
  避免指标丢失;添加 heartbeat_session_id 防跨进程去重误判
- Hub transport: Redis SETNX 心跳去重,避免多 worker 重复写库;
  ACK 回显 heartbeat_id 供 Rust 端匹配
- ProxyNodeService.heartbeat: 改用 SQLAlchemy atomic update 原子累加
  指标,避免 ORM read-modify-write 的并发覆盖问题
- 启动顺序修正: tunnel 状态重置移到 Hub 连接建立之前,避免竞态
- OAuth 批量导入: 动态超时(默认30s,走代理60s),Kiro 适配器透传
- 提取 normalize_heartbeat_id 到 tunnel_protocol 共享模块,消除重复
2026-03-02 12:27:51 +08:00
fawney19 f3b9f42202 refactor(tunnel): 移除直连tunnel模式,统一使用Hub转发
- 删除 TunnelTransport 及 TunnelManager 相关引用,所有 tunnel 请求统一走 Hub
- 简化 health_scheduler,不再依赖进程内 TunnelManager 状态判断节点在线
- 简化 resolver,移除本地 tunnel miss 判断与多 worker 告警逻辑
- 简化 service 中 tunnel 连通性检测,统一以 DB 状态为准
- 启动/关闭流程移除 hub_enabled 分支,始终初始化 Hub 连接
- Rust 端 RequestMeta.timeout 增加浮点数反序列化支持,Python 端确保发送整数
2026-03-02 11:45:30 +08:00
fawney19 0564893c4f refactor: Hub二进制改为Docker构建时下载,优化部署流程与worker初始化
- Dockerfile: 移除COPY预编译二进制,改为构建时通过HUB_TAG从GitHub Release下载
- CI: 移除artifact上传/下载步骤,通过build-args传递Hub tag
- deploy.sh: 重构参数解析,支持--hub-tag指定版本,跟踪tag变化触发重建
- build.sh: 新增--image模式支持构建并推送Hub Docker镜像
- hub.rs: worker连接时同步所有节点在线状态,避免状态不一致
- proxy_nodes: 启动时主动建立Hub worker连接,消除懒连接窗口期
- docker-compose.yml: app镜像支持APP_IMAGE环境变量配置
- README: 更新部署文档,推荐本地构建方式
2026-03-02 11:24:14 +08:00
fawney19 c9dbe7936b fix: 补充tunnel端点nginx代理的路径前缀 2026-03-02 04:18:28 +08:00
fawney19 3f7a3d7600 fix: 修复tunnel端点nginx代理端口未替换的占位符问题 2026-03-02 04:14:48 +08:00
fawney19 5b9d9452c9 fix: 修改hub构建方式 2026-03-02 04:05:40 +08:00
fawney19 b7ef900181 fix(deploy): 恢复部署时自动拉取最新代码 2026-03-02 03:09:15 +08:00
fawney19 3eef673885 fix: 添加cargo清华镜像源,加速Hub本地构建 2026-03-02 03:04:56 +08:00
fawney19 039a18c243 feat(tunnel): 引入 aether-hub 帧路由器,支持多 worker 共享 tunnel 连接
新增 Rust 实现的 aether-hub 服务,作为 Docker 容器内部 WebSocket 帧路由器,
解决多 Gunicorn worker 进程间 tunnel 连接隔离问题。

主要改动:
- 新增 aether-hub Rust 项目,实现 proxy/worker 双向帧路由与 stream_id 重映射
- 新增 HubConnectionManager/HubTunnelTransport,worker 通过 Hub 转发 tunnel 帧
- 新增 create_tunnel_transport 工厂函数,按运行环境自动选择 Hub 或直连模式
- 新增 NODE_STATUS 广播机制,Hub 实时通知所有 worker 节点连接状态变化
- CI/CD 新增 build-hub job,Dockerfile 集成 Hub 二进制,deploy.sh 适配 Hub 构建
- 默认 GUNICORN_WORKERS 从 4 降为 2
2026-03-02 02:43:14 +08:00
fawney19 97d42703da feat(codex): 拆分openai:compact为独立端点,简化Codex请求为透传模式
- 新增openai:compact端点类型(EndpointKind.COMPACT),独立于openai:cli
- OpenAICompactAdapter继承OpenAICliAdapter,自动标记compact模式
- Codex请求补丁改为纯透传:仅清理内部标记,不再修改客户端payload
- stream_policy支持openai:compact独立策略,compact端点移除stream字段
- candidate_builder支持compact回退到cli端点
- auth_type: vertex_ai重命名为service_account,保持向后兼容
- Vertex Provider新增api_formats与auth_type组合校验
- KeyAllowedModels对话框改为从Provider获取模型,展示provider_model_name
- Dialog内Select组件自动禁用Portal,修复层级遮挡问题
- 新增Codex compact端点回填迁移脚本
2026-03-01 23:55:26 +08:00
fawney19 4bf3a453e7 feat(vertex-ai): 重构 Vertex AI 为插件化 adapter,支持 service_account 认证与动态路由
将 Vertex AI 从 transport.py 的硬编码逻辑重构为独立的 plugin adapter,
支持 service_account/oauth 认证类型、模型格式自动识别、区域路由和 URL 构建。
前端新增 Key 认证类型选择和 Service Account 配置表单。

Co-authored-by: NyaDoo <[email protected]>
Closes #194
2026-03-01 23:32:48 +08:00
fawney19 a137601728 feat(codex): 支持compact端点非流式请求,更新请求头与字段清理
- Codex适配器支持compact端点:非流式请求使用application/json Accept头,
  stream_policy根据compact上下文返回FORCE_NON_STREAM
- 更新Codex请求头格式:添加Version/Connection头,header key首字母大写
- 简化include列表处理:normalizer和request_patching统一强制为固定列表
- 清理Codex不支持的字段:truncation、context_management、user
- 默认instructions改为空字符串
- 前端用量页面:用户页面使用前端筛选后总数,避免不必要的后端分页请求
2026-03-01 18:20:42 +08:00
fawney19 d4840df447 feat(tunnel): 重连指数退避、多worker兼容与手动节点请求计数
- Proxy tunnel 重连策略从固定1s改为指数退避+jitter,首次重试立即执行,
  稳定连接30s后重置退避计数,上限3s保证快速恢复
- 多连接启动时增加错峰延迟,避免同时发起连接风暴
- 服务端 tunnel ping间隔和空闲超时支持环境变量配置
- 修复多worker启动时tunnel状态重置逻辑,仅leader执行重置避免覆盖其他worker连接
- Resolver增加本地tunnel缺失的限频告警和更短缓存TTL,加速多worker场景恢复
- 用量记录中统计手动代理节点的请求数和失败数
2026-03-01 17:37:11 +08:00
fawney19 2c1e51a490 refactor(compression): 请求压缩策略改为跟随客户端行为,响应支持gzip压缩
- 移除全局 ENABLE_REQUEST_COMPRESSION 配置,改为根据客户端 Content-Encoding
  决定是否对上游请求体进行 gzip 压缩
- 非流式响应根据客户端 Accept-Encoding 返回 gzip 压缩的 JSON
- ApiRequestContext 记录客户端编码偏好并透传至 handler 链路
- 新增 http_compression 模块统一处理压缩相关判断逻辑
- 上游请求头丢弃列表新增 content-encoding 防止客户端值泄露
- ensure_json_body 支持解压 gzip 编码的请求体
2026-03-01 15:52:32 +08:00
fawney19 2dcccc9820 fix(kiro): 修复工具schema兼容性、消息交替和重复内容问题
- 递归清理工具schema中Kiro不支持的additionalProperties和空required字段
- 将thinking prefix注入从history移到currentMessage,仅作用于当前轮次
- 修复连续assistant消息缺少user消息导致角色不交替的问题
- 增加流式content事件去重,跳过Kiro发送的重复内容
2026-03-01 14:25:38 +08:00
fawney19 8fa97ab8da feat(provider): 更新请求模型添加格式转换开关字段 2026-03-01 13:08:20 +08:00
fawney19 a66fa9792d fix(stream): 修复流式请求超时和取消归因问题
- 流式请求 timeout 改为 None,避免 provider.request_timeout 作为整条流总时长超时导致长响应被硬切断
- 重构 CancelledError 断连归因逻辑:提取探测方法并支持多次重试确认,降低误判率
- 新增 cancelled_unknown 状态处理断连检测不确定的场景,避免错误归因为 server_cancelled
- 在 upstream_response 中记录取消详情,便于排查
- 新增断连归因逻辑的单元测试
2026-03-01 13:02:26 +08:00
fawney19 dc2bc83c17 refactor(frontend): 改进故障转移规则对话框的状态码输入与校验
状态码输入从即时双向解析改为原始字符串绑定,保存时统一校验,
增加明确的错误提示;提取正则验证函数;优化小屏布局防止按钮挤压
2026-03-01 12:37:08 +08:00
fawney19 8b0e92e408 perf(frontend): 管理页面更新操作改为局部刷新,避免全量重载列表
Provider、API Key、Pool Key、Management Token 的更新操作
完成后直接替换本地列表中对应记录,仅创建操作保留全量刷新。
2026-03-01 11:57:09 +08:00
fawney19 b61fc4eb6b feat(test): 模型测试支持故障转移,展示每次尝试详情
- 后端新增 test-model-failover 接口,支持 global/direct 两种测试模式
- 利用 FailoverEngine 遍历候选并记录每次尝试的状态、延迟、错误等详情
- 前端 ModelsTab/ModelMappingTab 切换到新接口,移除格式选择下拉菜单
- 新增 TestResultDialog 组件,失败时展示候选尝试详情表格
- 简化组件 props 传递,移除不再需要的 endpoints/mappingPreview 依赖
2026-03-01 03:37:41 +08:00
fawney19 fea3d183bf Merge pull request #195 from AAEE86/keys
feat(pool): 新增账号配额展示并清理 Codex 旧限额字段
2026-03-01 00:59:49 +08:00
fawney19 20ed9cd123 feat(proxy): worker 退出时优雅关闭 tunnel 连接,提升 max-requests 默认值
- TunnelManager 新增 shutdown_all 方法,drain 飞行中请求后发送 GoAway
- shutdown 期间标记 draining 拒绝新请求进入
- gunicorn max-requests 默认值从 4000 提升到 50000,减少不必要的 worker 重启
2026-03-01 00:54:37 +08:00
AAEE86 d7a8a89aa7 feat(pool): 新增账号配额展示并清理 Codex 旧限额字段
- 后端池子 Key 列表新增 account_quota 字段,按 provider_type 生成配额摘要(codex/kiro/antigravity)
- 前端 PoolManagement 新增“配额”列与进度条展示,桌面端与移动端同步支持
- Provider 详情页移除 Codex code_review 限额展示,仅保留周限额与 5H 限额
- 清理 codex usage parser/realtime quota 中 code_review 相关解析与比较逻辑
- 同步更新调度与配额相关测试,改为忽略无关历史字段
2026-03-01 00:32:14 +08:00
fawney19 005cc3e388 feat(failover): 支持 Provider 级别故障转移规则,默认全部转移策略
- 新增 failover_rules 配置:支持 success_failover_patterns(成功响应匹配时转移)
  和 error_stop_patterns(错误响应匹配时终止),支持按 status_code 过滤
- 修改默认转移策略:ErrorClassifier 不再返回 RAISE,所有错误默认继续转移
- TaskService 中客户端错误不再直接抛出,改为 break 继续尝试下一个候选
- 修复 proxy tunnel 连接/断连竞态:引入 per-node 锁和事件时间戳排序
- 优化 ProxyNode 状态判定:OFFLINE 统一由心跳超时判定,兼容多 worker 场景
- has_tunnel 改为纯检查方法,避免在 finally 块中误清理新注册连接
- Redis stream NOGROUP 异常自愈处理
- OAuthAccountDialog 输入框焦点样式补全
2026-03-01 00:21:16 +08:00
fawney19 fbcb54a8a5 feat(oauth): 优化凭据导入,支持多文件选择与更多 JSON 格式
前端:简化导入界面状态管理,支持多文件拖拽/选择并自动合并内容,
移除冗余的 importFileName/manualPasteText 状态。
后端:_parse_tokens_input 新增支持 JSON 对象数组和单个 JSON 对象格式解析。
2026-02-28 22:06:21 +08:00
github-actions[bot] 85a126f48a chore(proxy): update download links for proxy-v0.2.1 2026-02-28 13:09:33 +00:00
fawney19 44cd35c10e chore(proxy): bump aether-proxy to 0.2.1 2026-02-28 21:02:21 +08:00
fawney19 a2d1cff3b0 perf(transport): 全链路传输压缩优化
- 上游请求启用 HTTP/2 (HPACK 头部压缩 + 多路复用),添加 h2 依赖
- 上游请求体超过阈值时自动 gzip 压缩,使用紧凑 JSON 序列化
- 添加 brotli 依赖,Accept-Encoding 支持 gzip/deflate/br
- 隧道帧压缩: Rust 端响应帧和 Python 端请求/响应帧均支持 gzip
- Rust 端压缩/解压逻辑统一提取到 protocol.rs
- Rust 端请求头构建改用 .headers() 替换 reqwest 默认值
- 新增 ENABLE_HTTP2、ENABLE_REQUEST_COMPRESSION 等环境变量配置
2026-02-28 21:02:21 +08:00
fawney19 3ff67fec2f Merge pull request #193 from AAEE86/keys
refactor(provider-keys): 拆分 keys 端点逻辑并补充配额刷新测试
2026-02-28 17:15:19 +08:00
AAEE86 6a8b5e6c8e fix(provider_keys): 提升 Codex 配额异步同步的可靠性与可观测性
- 为 flush 引入 FlushResult,统一返回更新数与重试批次
- 增加指数退避与失败日志限流,成功后重置 backoff
- 批量提交失败时回退到单条提交,降低整批失败风险
- 补充测试,覆盖 flush 重试与提交失败回退场景
2026-02-28 16:54:37 +08:00
AAEE86 92e9caf57e feat(usage): 支持 Codex 配额响应头实时异步同步
- 新增 parse_codex_usage_headers,统一解析响应头中的 Codex 配额信息
- 新增实时配额同步与异步调度器,按 provider_api_key_id 去重并后台落库
- 在 usage 记录与结算流程中接入配额同步投递,并在应用生命周期中启动/停止调度器
- 补充 codex_realtime_quota 与 codex_quota_sync_dispatcher 相关单元测试
2026-02-28 16:35:56 +08:00
AAEE86 10ccbf109c Merge branch 'fawney19:master' into keys 2026-02-28 16:13:30 +08:00
fawney19 f2dc51434d refactor(models): 删除拆分的模型子模块文件,统一回 database.py
- 删除 _base.py, auth.py, misc.py, model.py, provider.py, stats.py, usage.py, user.py
- Usage 表新增 cache_creation_input_tokens_5m/1h 字段支持按缓存 TTL 细分计费
- Model.global_model_id 改为 nullable=False,强制关联 GlobalModel
2026-02-28 16:12:32 +08:00
fawney19 9d751e3525 fix: 修复迁移脚本 2026-02-28 15:33:43 +08:00
fawney19 27047e5880 fix(migration): backfill 前清空 user_model_usage_counts 确保幂等性 2026-02-28 15:28:34 +08:00
fawney19 4fdebfc78e perf(usage): 优化 admin usage records 查询性能
- count 查询按需 JOIN,避免不必要的表关联
- 前端 onMounted 将 stats/heatmap/records/users 全部并行加载
- 调整缓存 TTL(聚合 30s->60s,列表 10s->15s)
- 为 request_candidates 添加复合索引优化 fallback/retry 查询
2026-02-28 15:09:21 +08:00
fawney19 11832edf49 Merge pull request #192 from AAEE86/master
refactor(usage): 移除时间线中的格式转换标签展示
2026-02-28 14:47:18 +08:00
fawney19 87d50052cc refactor(proxy): 移除 unhealthy 状态、前端展示失败率替换连接数、命令提示修正
- 移除 ProxyNodeStatus.UNHEALTHY 枚举值,仅保留 online/offline
- 迁移脚本将已有 unhealthy 数据迁移为 offline,upgrade/downgrade 均有幂等性保护
- 前端代理节点列表用失败率列替换连接数列,超过 5% 高亮显示
- 节点列表排序从 updated_at DESC 改为 name ASC
- Rust 端命令行提示从 aether-proxy 改为 ./aether-proxy
2026-02-28 14:44:44 +08:00
AAEE86 08b89b7ef8 refactor(provider-keys): 拆分 keys 端点逻辑并补充配额刷新测试
将 admin keys 接口的创建、更新、查询、删除、导出与配额刷新逻辑迁移到 provider_keys 服务层

新增 auth_type 归一化、重复校验、响应构建与 quota_refresh(codex/kiro/antigravity)模块,并补充对应单元测试
2026-02-28 14:12:08 +08:00
fawney19 4b02078b60 fix(proxy): 自动注册节点时按 ip+port 匹配而非 name 2026-02-28 14:10:44 +08:00
fawney19 1d644de500 fix: 修复迁移脚本 2026-02-28 14:03:42 +08:00
fawney19 54530faf03 feat(proxy): 节点状态简化、连接事件记录、可靠性指标与批量删除
- 移除 UNHEALTHY 中间状态,节点状态简化为 ONLINE/OFFLINE
- 新增 proxy_node_events 表记录 tunnel 连接/断开/错误事件
- 新增 failed_requests/dns_failures/stream_errors 可靠性指标(增量累加)
- tunnel 重连改为固定 1s 延迟,移除指数退避逻辑
- resolver/service 改为以 TunnelManager 内存状态判断节点可用性,避免 DB 竞态
- 修正 Claude cache_control 字段格式,使用 ttl 字段控制缓存时长
- 移除前端手动勾选 capability 的 UI,改为从价格配置自动推断
- 新增全局模型批量删除 API,替换前端并行单个删除
2026-02-28 13:52:32 +08:00
fawney19 ecb16d345a feat: 缓存计费细分、能力匹配优化、用户模型调用计数
1. 缓存创建 tokens 区分 5min/1h TTL,支持按缓存时长差异化计费
   - Usage 表新增 cache_creation_input_tokens_5m/1h 字段
   - Claude handler 解析新格式 (ephemeral_5m/1h, claude_cache_creation_5/1h)
   - 计费规则支持 cache_ttl_pricing 覆盖 cache_creation 价格

2. 能力匹配机制优化
   - COMPATIBLE 能力不再硬过滤,改为排序阶段通过 capability_miss_count 优先级处理
   - cache_1h 改为 COMPATIBLE + REQUEST_PARAM(自动检测请求体中的 ttl=1h)
   - gemini_files 改为 EXCLUSIVE + REQUEST_PARAM(自动检测 fileData.fileUri)
   - 移除前端模型偏好/能力配置 UI(不再需要用户手动配置)

3. 新增用户-模型维度调用次数计数器 (UserModelUsageCount)
   - 原子递增,避免从 Usage 表聚合查询
   - 前端模型目录和用户可用模型列表展示调用次数

4. 其他改进
   - global_model_id 改为必填(NOT NULL),清理孤立模型
   - 模型映射对话框支持从上游获取模型列表并分组折叠
   - 端点测试不再依赖端点启用状态
   - 异步任务页面对普通用户隐藏用户信息列
   - Dashboard 响应式布局断点调整 (sm -> lg)
   - 号池管理仅展示已启用号池的提供商
2026-02-28 11:45:04 +08:00
AAEE86 daecdb7676 feat: 统一供应商凭据标签并优化统计展示布局 2026-02-28 10:09:51 +08:00
AAEE86 423eb95f7a refactor(usage): 移除时间线中的格式转换标签展示 2026-02-28 09:42:33 +08:00
fawney19 82bbed2720 Merge pull request #191 from AAEE86/master
feat(providers): 资源统计按 provider_type 显示密钥/账号
2026-02-28 09:18:55 +08:00
AAEE86 90e804f608 feat(providers): 资源统计按 provider_type 显示密钥/账号 2026-02-28 08:58:12 +08:00
fawney19 e748277902 feat(proxy): 安全加固与架构优化
- 引入 SafeDnsResolver 消除 DNS rebinding TOCTTOU 漏洞,DNS 缓存改为多地址存储
- 扩展私有 IP 检测范围(CGNAT 100.64/10、基准测试 198.18/15、保留 240/4)
- 请求处理增加 hop-by-hop 头过滤、URL scheme 校验、超时范围限制
- 动态配置从 RwLock 切换到 ArcSwap 实现无锁读取
- 启动注册失败的服务器支持后台自动重试
- WebSocket 帧大小上限提升至 64MiB 匹配 Python 端
- 心跳支持动态间隔更新,新增 failed_requests/dns_failures/stream_errors 指标
- 配置启动校验、systemd UMask=0077、配置文件权限 600
- Python 端支持 per-connection max_streams(X-Tunnel-Max-Streams)
2026-02-28 01:32:28 +08:00
fawney19 2a0c684e88 feat: 新增全局模型批量管理功能,优化提供商更新接口
- 模型管理页面新增批量管理对话框,支持搜索、快捷筛选和批量删除
- 快捷筛选支持:无提供商、无活跃提供商、已禁用、未调用、无价格
- 提供商 PATCH 接口返回完整的 ProviderWithEndpointsSummary
- 修复系统级格式转换开关的 tooltip 文案和按钮高亮样式
2026-02-28 00:01:15 +08:00
fawney19 5bfaee47cc refactor(proxy): 端点检查统一使用 proxy_config 替代 proxy_param
将 endpoint_checker、handler_adapter_base、gemini adapter 及
provider_query 中的 proxy_param 参数替换为 proxy_config,
通过 build_proxy_client_kwargs 统一构建代理客户端参数,
以支持 tunnel 模式代理。同时清理未使用的 import。
2026-02-27 22:41:31 +08:00
fawney19 8de2f41924 fix(proxy-tunnel): 修复隧道连接池竞态与跨平台兼容问题
- 修复 TCP keepalive with_retries 在 Windows 上不可用的编译问题
- dispatcher 中 try_send 失败时记录警告日志而非静默丢弃
- 优化 handler_handles 清理策略,每 64 帧定期清理
- StreamState 记住原始连接引用,清理时避免连接池竞态
2026-02-27 21:41:43 +08:00
fawney19 ee356c5e6e feat(proxy-tunnel): 实现隧道连接池与 TCP 底层优化
Rust 端:
- 支持每个 server 多条并行 WebSocket 连接 (tunnel_connections 配置)
- 手动控制 TCP 连接: connect/handshake 超时、keepalive、NODELAY (socket2)
- 预构建共享 TLS ClientConfig 避免每次重连重新解析根证书
- 增加 stale timeout 检测无数据连接,智能 backoff 按连接存活时长重置
- 每条连接独立 reconnect 计数器,仅主连接 (conn_idx=0) 发送心跳
- dispatcher 的错误帧和 PONG 改用 try_send 避免阻塞读循环
- stream_handler 增加 frame 发送超时保护防止写阻塞

Python 端:
- TunnelManager 改为连接池,按 least-loaded 策略分配请求
- handle_incoming_frame 按连接实例路由,unregister 精确移除单条连接
- 心跳和 PONG 回复改为 fire-and-forget 避免阻塞主读循环
- send_frame 增加超时保护防止 TCP 写阻塞级联
- WebSocket 先 accept 再认证,auth 加超时
- 调整 idle timeout (90s) 和 ping 间隔 (15s)
2026-02-27 21:25:42 +08:00
fawney19 a174cf1b02 feat(proxy-tunnel): 增强隧道连接稳定性与恢复速度
- writer 增加 WebSocket Ping keepalive,防止中间代理空闲超时断开
- 服务端增加应用层 PING 循环(30s 间隔),空闲超时延长至 180s
- 重连基础延迟从 1000ms 降低到 500ms
- 节点缓存 TTL 缩短至 15s,不可用节点 TTL 缩短至 5s 加速恢复感知
- 心跳检测间隔从 30s 缩短到 15s
2026-02-27 19:51:38 +08:00
fawney19 934723f5f5 fix(frontend): 移除代理节点配置的号池模式限制
取消 Popover 组件的 v-if="provider.pool_advanced" 条件,
使代理节点配置在所有模式下均可使用。
2026-02-27 19:25:53 +08:00
fawney19 892c4235ec fix(frontend): 修正 system.ts 中 client 的导入方式为默认导入 2026-02-27 19:15:38 +08:00
fawney19 ddeb357c0e feat(proxy): 增加 0.1.x 到 0.2.0 配置自动迁移,放宽 max_retries 上限至 999
- proxy: 启动时检测旧版配置并自动迁移(delegate_* -> upstream_*、单服务器 -> [[servers]]),备份原文件为 .v1.bak
- proxy: 升级后 systemd 重启改为 best-effort,失败不中断升级流程
- provider: max_retries 上限从 10 放宽到 999(前端、后端模型同步调整)
2026-02-27 19:12:02 +08:00
github-actions[bot] 7cb007b445 chore(proxy): update download links for proxy-v0.2.0 2026-02-27 10:52:41 +00:00
fawney19 50262a2f02 chore(proxy): bump version to 0.2.0 2026-02-27 18:46:01 +08:00
fawney19 178fe4be4b Merge pull request #189 from AAEE86/fix
fix(frontend): 修复 Provider Key 列表只显示 100 条的问题
2026-02-27 18:37:16 +08:00
fawney19 4855e7cbca fix(task-poller): 捕获 CancelledError 避免关闭时产生 traceback
视频任务轮询在应用关闭时,Redis 异步操作被取消会抛出
CancelledError,由于其继承自 BaseException 而非 Exception,
原有的异常处理无法捕获,导致 APScheduler 打印完整 traceback。
拆分 poll_pending_tasks 为入口方法和 _do_poll 内部方法,
在入口层捕获 CancelledError 后静默返回。
2026-02-27 18:23:07 +08:00
fawney19 d2450cbdc1 feat(claude-code): 增加 TLS 指纹伪装、Cache TTL 统一、CLI 限制与流超时冷却
- 新增 curl_cffi Transport,支持真实浏览器 TLS 指纹伪装 (Chrome/Node.js)
- 增加 Cache TTL Override 功能,强制统一 cache_control 类型防止行为指纹差异
- 增加 CLI-only 客户端限制,支持仅允许 Claude Code CLI 访问
- 池健康策略增加 stream timeout 计数与自动冷却机制
- OAuth 账号 Region 选择改为从 AWS API 动态获取,支持搜索和自定义输入
- 前端 PoolConfigDialog 增加对应配置 UI
2026-02-27 18:10:27 +08:00
AAEE86 bfee1019ed fix(frontend): 修复 Provider Key 列表只显示 100 条的问题
- 在 getProviderKeys 中增加 skip/limit 自动分页拉取
  - 默认每页 1000,循环获取直到无更多数据
  - 避免后端默认 limit=100 导致账号展示被截断
2026-02-27 15:07:44 +08:00
fawney19 76a6d0ce8e feat(pool): 增加 OAuth 账号池管理功能
- 新增 pool manager / strategy / health_policy / cost_tracker / redis_ops 等核心模块
- 新增 pool admin API 路由与 schemas
- 新增 OAuth 账号类型解析 (oauth_plan)
- 前端增加 PoolManagement 页面、PoolConfigDialog、PoolImportDialog、PoolStatusCard 组件
- 补充 pool config / cost tracker / health policy / manager / strategy / trace 等测试
2026-02-27 13:58:58 +08:00
fawney19andAAEE86 579b5e4623 feat(provider): 增加 Claude Code 适配器、高级配置能力与 OAuth 账号类型统一解析
- 新增 Claude Code provider adapter (context, envelope, plugin, constants)
- 扩展 provider admin 路由,支持 Claude Code 高级配置 (CRUD)
- 统一 OAuth 账号类型解析逻辑,前后端对齐
- 重构 BatchAssignModelsDialog / ModelMappingDialog,简化组件逻辑
- handler 基类增强: request_builder 支持 Claude Code 信封格式
- CLI stream/sync mixin 适配 Claude Code 流式与同步模式
- 扩展 candidate builder / failover / scheduler 对 Claude Code 的支持
- 前端增加请求时间线可视化 (HorizontalRequestTimeline)
- 补充 Claude Code envelope / runtime controls / distributed sessions 等测试

Closes #183
Closes #185

Co-Authored-By: AAEE86 <[email protected]>
2026-02-27 13:54:46 +08:00
fawney19 f2f2a2dbc4 fix(proxy-node): 心跳和健康检查增加 tunnel 节点状态修正能力
- 心跳处理:收到心跳说明 tunnel 连通,若状态非 ONLINE 则修正
- 健康检查:移除 OFFLINE 过滤,允许检测 tunnel 重连后的状态恢复
- 前端配额刷新:就地更新 key metadata,避免重拉列表导致分页重置
2026-02-26 17:53:31 +08:00
fawney19 3fff147b9b Merge pull request #184 from rcdfrd/feat/new-api-checkin-apikey-support
feat(new-api): 签到支持 API Key 认证模式
2026-02-26 17:52:37 +08:00
rcdfrd a25f491f45 feat(new-api): 签到支持 API Key 认证模式
- 无 Cookie 时不再直接跳过,改为尝试以 API Key 发起签到请求
- 401/403 及认证失败类消息在 API Key 模式下静默跳过,不再误报 cookie_expired
- 将 message.lower() 预计算为 message_lower,避免重复调用
- 修正 already_indicators 与 auth_fail_indicators 中各条目也做 lower 处理,确保大小写不敏感比较一致
2026-02-26 15:42:42 +08:00
fawney19 8c2928c39d refactor(proxy-node): 移除非 tunnel 模式兼容,注册和心跳统一为 tunnel 模式
- 注册接口移除 tunnel_mode 参数,固定按 name upsert 并标记为 tunnel 模式
- 心跳接口拒绝非 tunnel 模式节点,返回升级提示
- 移除按 ip+port 查找的旧模式分支
2026-02-26 13:54:32 +08:00
fawney19 7e73e7fab2 fix(proxy-node): tunnel 模式节点注册不再覆盖由连接管理的状态
register_node 对 tunnel 模式已有节点不再写 status 字段,
状态完全由 _update_tunnel_status 和 health_scheduler 管理,
避免心跳注册将已连接的 ONLINE 状态覆盖为 UNHEALTHY。
2026-02-26 13:38:08 +08:00
fawney19 61535dfb7e feat(proxy-node): tunnel 节点支持连通性测试
通过 TunnelTransport 经 WebSocket tunnel 发送测试请求,
测量往返延迟并获取出口 IP,与手动代理节点测试语义一致。
2026-02-26 13:16:06 +08:00
fawney19 54332d31bc fix(proxy-node): 监控 writer task 退出以避免 tunnel 连接半关闭时 dispatcher 阻塞
当对端关闭连接导致 write half 退出但 read half 仍打开时,
dispatcher 会在 ws_stream.next() 上永久阻塞。通过在 tokio::select!
中监控 writer_handle,检测到 writer 退出后立即触发重连。
2026-02-26 13:07:42 +08:00
fawney19 67b8eb5c2f fix(proxy-node): tunnel 模式节点状态由连接管理,心跳不再覆盖
- resolver 中增加 tunnel 未连接节点的过滤,避免请求路由到不可达节点
- 心跳处理中 tunnel 模式节点仅更新指标,不改变在线状态
2026-02-26 12:50:08 +08:00
fawney19 27cef96789 refactor(proxy-node): 统一代理解析,全面支持 tunnel 模式
新增 resolve_ops_proxy_config 合并 proxy 和 tunnel_node_id 的解析,
避免各架构重复调用 _resolve_effective_node。所有架构连接器
(anyrouter/nekocode/sub2api/yescode) 和 HTTPClientPool 均适配
tunnel 模式,通过 TunnelTransport 替代传统代理。
2026-02-26 12:25:23 +08:00
fawney19 560345a889 feat(orchestration): 增加 AWS 账号被暂停 (suspended) 的错误识别和自动停用处理
- ErrorClassifier 新增 403 suspended 状态检测,归类为 ProviderAuthException
- ErrorHandlerService 新增 _is_account_suspended 静态方法,匹配多种 suspended 错误文本
- 403 suspended 的 OAuth key 自动标记为账号异常并停用
- 重构 _mark_oauth_key_blocked 支持自定义 reason 参数
- 将 _is_account_validation_required 调用改为静态方法调用
2026-02-26 11:32:31 +08:00
fawney19 f41bfecf0b feat(usage): 增强 cURL/Replay 对 Vertex AI、OAuth 和 envelope 提供商的支持
- _resolve_provider_auth 返回 decrypted_auth_config 用于 Vertex AI URL 构建
- _build_provider_url_safe 新增 API 格式默认路径回退及模板变量防护
- Replay 适配器基于 api_format 智能匹配端点和 Key
- Replay 适配器支持 envelope 包装(kiro/codex/antigravity 等特殊提供商)
- 复用 target_provider_obj 减少重复 Provider 查询
2026-02-26 10:32:29 +08:00
fawney19 80438e1d61 fix(proxy-node): 修复服务重启后 tunnel 连接状态不一致的问题
- 启动时重置 DB 中残留的 tunnel_connected=True 状态
- health_scheduler 以 TunnelManager 内存实际连接为准判断节点状态
- tunnel 模式注册时初始状态设为 UNHEALTHY,等 tunnel 连接后再上线
2026-02-26 09:15:00 +08:00
fawney19 19f1be03fa feat(nginx): 添加 WebSocket 隧道端点的 nginx 代理配置 2026-02-26 03:33:53 +08:00
fawney19andAAEE86 b6ca16e084 fix(headers): 兼容非 ASCII header 值,避免 httpx ASCII 编码报错
将 HeaderBuilder 中的 latin-1 透传方案替换为 ASCII 归一化策略:
- 对 x-codex-turn-metadata 做 JSON 重编码(ensure_ascii=true)保留语义
- 其他非 ASCII 头值仅转义非 ASCII 字符为 \uXXXX,保留 ASCII 字符原样
- 新增单测覆盖中文 header 与 codex turn metadata 场景

Close #180

Co-Authored-By: AAEE86 <[email protected]>
2026-02-26 02:56:54 +08:00
fawney19 c0b80c923a refactor(usage): 移除 RequestHeadersContent 中未使用的 diff props
移除 clientHeadersWithDiff 和 providerHeadersWithDiff 属性及相关计算逻辑
2026-02-26 02:36:37 +08:00
fawney19 61e0959a06 Merge branch 'feat/compare-view' 2026-02-26 02:32:29 +08:00
fawney19 0ecf8b703e feat(api-keys): 支持独立密钥额度重置(手动+定时自动)
- 新增手动重置接口 PATCH /api/admin/api-keys/{id}/reset-usage
- 前端 ApiKeys 页面增加重置按钮,支持确认后归零已使用额度
- 新增独立密钥额度定时自动重置任务,支持配置周期和执行时间
- 支持 all/selected 两种重置模式,selected 模式可指定密钥
- 移除已废弃的 CleanupScheduler 兼容别名
2026-02-26 02:16:48 +08:00
fawney19 1a1bb0a99c feat(admin): 新增数据管理模块,支持分类清空系统数据
添加 purge API 支持按类别清空配置、用户、使用记录、审计日志、请求体和统计数据,
前端系统设置页新增数据管理区块。
2026-02-26 01:20:24 +08:00
fawney19 5415057a5d fix(models): 模型创建对话框支持连续添加
创建模式下提交后保持对话框打开,方便批量添加模型;
编辑模式提交后仍正常关闭对话框。按钮文案调整为"添加"。
2026-02-25 23:33:16 +08:00
fawney19 a2493b4bc0 fix(compatibility): 同族格式透传不再依赖格式转换开关
将 data_format_id 相同的格式对(如 claude:chat / claude:cli)的透传判断
提前到三层开关检查之前,使其无需开关即可直接透传。
同时用 pytest.mark.parametrize 精简同族格式测试用例。
2026-02-25 23:06:25 +08:00
fawney19 fd9040b9aa refactor(proxy): 将 aether-proxy 从 HMAC 正向代理迁移到 WebSocket 隧道模式
移除 HMAC 认证、TLS 自签名证书、HTTP CONNECT 代理和代发(delegate)模式,
改为 aether-proxy 主动通过 WebSocket 连接 Aether 服务端建立隧道。

Aether 服务端新增:
- WebSocket 隧道端点 (proxy_tunnel.py)
- TunnelManager 管理隧道连接和请求分发
- TunnelTransport 作为 httpx 自定义 transport 层
- 基于二进制帧的隧道协议 (tunnel_protocol.py)

aether-proxy (Rust) 重构:
- 新增 tunnel 模块 (client/dispatcher/stream_handler/protocol)
- 支持多 Aether 服务端连接 ([[servers]] 配置)
- 移除 proxy/auth/delegate 模块和 hyper 依赖
- 改用 tokio-tungstenite 实现 WebSocket 客户端

同时:
- 添加浏览器指纹 Headers 绕过 Cloudflare 防护
- 删除节点时自动清理 Provider/Endpoint 的代理引用
- 数据库迁移: 新增 tunnel_mode/tunnel_connected/tunnel_connected_at 字段
2026-02-25 21:59:29 +08:00
LewisPen adaf9e4b93 feat(usage): 响应头 Tab 增加并排对比模式
泛化 RequestHeadersContent 组件支持任意 header 对,
响应头 Tab 复用同一组件实现客户端/提供商响应头 Diff 对比。
2026-02-24 15:25:59 +08:00
fawney19 39b036abd5 refactor(docs/home): 重构首页导航和文档结构
- README 新增架构图(明暗主题 SVG),更新 Proxy 描述
- 首页导航栏将"文档"链接从底部按钮区移至顶部导航
- 删除独立的 body-rules-spec 文档
- GuideLayout 路由切换时自动滚动到顶部
- 架构图组件移除网格背景
2026-02-24 12:15:14 +08:00
fawney19 837bed3d47 feat(provider): 支持 Provider 级别代理节点配置,修正配额分组匹配
- Provider 详情抽屉新增代理节点设置入口(Globe 图标),支持选择/清除代理
- 更新 Antigravity 配额分组:修正模型匹配规则以适配当前模型命名
- 简化上游元数据合并逻辑,以上游返回为准,不再保留已下架模型条目
2026-02-24 02:44:15 +08:00
fawney19 7e447fe54e feat(antigravity): 对齐 AM 客户端标识,前端 OAuth 增加 TOTP 支持
- User-Agent 改为 Electron 浏览器格式,新增 x-client-name/x-client-version/x-vscode-sessionid/x-goog-api-client header
- 移除 MODEL_ALIAS_MAP 别名映射,改为仅剥离 -online 后缀
- thinking 自动注入关键字新增 gemini-3.1-pro
- 新增定时 Antigravity UA 版本刷新任务(每 6 小时 + 启动时)
- 前端 OAuth 设备授权新增 TOTP 验证码生成(otpauth),Region 改为按钮选择
- SSO OIDC 轮询日志 authorization_pending/slow_down 降级为 debug
2026-02-24 01:52:53 +08:00
fawney19 cd70c9148e refactor(guide): 精简架构说明布局和样式细节
- 移除副标题描述段落,采用紧凑编号列表替代圆形大卡片布局
- 精简文案表述,缩减流程图间距和元素尺寸
- 移除架构图提供商标签的 hover 动效
- 调整核心调度条的字重和边框样式
2026-02-23 22:58:52 +08:00
fawney19 cd05ec770c fix(guide): 架构图标签改为产品名称(Claude/Gemini)并调整节点间距
- 将 Source/Output 标签从 Anthropic/Google 改为 Claude/Gemini
- 增大虚线框与引擎卡片的垂直间距,改善布局层次
- 同步调整连接线路径坐标和动画时长
2026-02-23 22:41:53 +08:00
fawney19 a7675606a1 fix(guide): 更新架构图核心模块标签和布局细节
- 新增"缓存亲和"功能标签
- 将"分布式智能路由抉择"改为"智能调度 / 故障转移"
- 调整标签间距从 gap-2 到 gap-1.5
2026-02-23 22:33:34 +08:00
fawney19 bdd9d0a3dd refactor(guide): 重构架构图为紧凑垂直布局,支持响应式缩放
将 ArchitectureDiagram 从 1300px 横向滚动画布改为 760x420 固定尺寸垂直流布局,
使用 CSS transform scale + ResizeObserver 实现自适应缩放,移除横向滚动条。
2026-02-23 21:56:20 +08:00
fawney19 e18a7ee435 refactor(guide): 重构指南页面为 Markdown 内容驱动架构
- 将各指南页面的内联硬编码内容抽取为独立 Markdown 文件
- 新增 MarkdownViewer 组件统一渲染 Markdown 内容
- 新增 ArchitectureDiagram 组件和架构演示组件
- 新增模块指南页面 (ModulesGuide)
- GuideLayout 支持子导航、页面过渡动画和图片 Lightbox
- guide-config 重构为支持 subItems 的导航结构
- 添加指南相关截图资源
2026-02-23 15:50:24 +08:00
fawney19 c43d22b0e4 fix(frontend): 移除缓存价格自动计算对 input_price > 0 的限制
当 input_price_per_1m 为 0 时,缓存价格(creation/read/1h)也应自动计算,
而非跳过赋值。
2026-02-22 15:26:40 +08:00
fawney19 1a45d0aa87 docs: 更新 README 项目定位描述,截图改为外链,新增 Aether Proxy 介绍段落
- 项目描述从"开源 AI API 网关"调整为"一站式 AI 基础设施平台"
- 移除内嵌截图,改为指向 GitHub Pages 的外链
- 新增 Aether Proxy 简介段落
- 精简部分 FAQ 内容
- aether-proxy 版本升至 0.1.6
2026-02-22 01:38:22 +08:00
github-actions[bot] 41835c8afe chore(proxy): update download links for proxy-v0.1.6 2026-02-21 17:21:11 +00:00
fawney19 5da9197eee feat(proxy): CI 添加 Docker 构建推送和 README 自动更新,HardwareTooltip 增强字段兼容
- build-proxy workflow 新增 docker job 构建多架构镜像推送 GHCR/Docker Hub
- build-proxy workflow 新增 update-readme job 自动更新下载链接表格
- Dockerfile 改用预编译二进制替代多阶段 Rust 编译
- 新增 docker-compose.yml 简化部署
- .env.example 精简为必要配置项
- README 补充 Docker 部署说明和下载表格标记
- HardwareTooltip 兼容 camelCase/snake_case 字段名,添加 pickRecord 辅助函数和原生 tooltip 回退
2026-02-22 01:13:03 +08:00
fawney19 cf2eee222e refactor: 前端全面替换 any 为 unknown 并统一错误处理,后端用量记录补写请求头/体
- 前端 API 层、stores、conversation 解析器、组件全面替换 any 为 unknown/具体类型
- 错误处理统一使用 parseApiError/getErrorStatus 替代 err.response?.data?.detail 模式
- 后端 handler/TaskService/UsageLifecycle/StreamTracker 链路传递 request_headers/request_body
- streaming/pending 状态更新时可补写客户端和提供商的请求头及请求体
- 新增 TaskService 和 UsageService 相关测试
2026-02-22 00:43:41 +08:00
fawney19 98e60e8f74 feat(kiro): 支持 AWS SSO OIDC 设备授权流程
为 Kiro provider 新增 Device Authorization 模式,替代原先禁用 OAuth 的限制:
- 后端实现 device-authorize / device-poll 端点,完成客户端注册、设备码签发、token 轮询和自动建 Key
- 前端 OAuthAccountDialog 新增设备授权 UI,支持 Start URL/Region 输入、验证链接跳转、倒计时和自动轮询
2026-02-21 22:30:38 +08:00
fawney19 1e4d0006b9 fix: 用量记录保留空 headers/body,格式转换支持同 normalizer 透传
- 用量记录和遥测写入中将 truthiness 检查改为 is not None,
  避免空 dict/list 被误丢弃
- FormatConversionRegistry 新增 _same_normalizer() 方法,
  共享 normalizer 的格式(如 claude:chat/claude:cli)视为同格式透传
- 前端请求详情抽屉按数据可用性智能选择默认数据源
- 视频任务 finalize 支持 headers/original_headers 等别名字段
2026-02-21 21:16:35 +08:00
fawney19 b649a69a5e fix: 迁移脚本补充 inspect 导入,HardwareTooltip 兼容多种硬件字段格式
- 迁移脚本缺少 inspect 导入导致运行时报错
- HardwareTooltip 使用 pickNumber/pickString 兼容不同节点上报的字段名
- 添加 tooltip 点击交互和 title 属性作为 fallback
2026-02-21 18:13:46 +08:00
fawney19 066fc01d2e fix(body_rules): 启用条件的规则跳过路径冲突校验
条件可能互斥,静态校验无法判断是否真正冲突,改为运行时处理。
2026-02-21 17:08:31 +08:00
fawney19 19dd297138 refactor(kiro): 拆分 auth_region/api_region,统一 region 解析逻辑
KiroAuthConfig 新增 auth_region(token 刷新端点)和 api_region(q.{region} 服务端点)字段,
通过 effective_auth_region() / effective_api_region() 方法统一各处散落的 region 回退逻辑,
与 kiro.rs 的 region 语义对齐。
2026-02-21 16:54:03 +08:00
fawney19 f470ab6ec8 fix: 刷新限额失败时异常消息为空则回退到异常类名 2026-02-21 15:47:22 +08:00
fawney19 414f4e40c7 refactor: Claude 适配器改用认证头区分 CLI/Chat 模式
将 build_claude_adapter 的判断依据从 x-app 自定义头改为标准认证头:
Bearer token (无 x-api-key) 走 CLI 模式,x-api-key 走 Chat 模式。
移除 ClaudeCliAdapter.get_cli_extra_headers 中不再需要的 x-app 头注入。
2026-02-21 15:39:59 +08:00
fawney19 bb7d393128 feat(body_rules): 支持通配符路径、范围索引、name_style action 和 $item 条件引用
- 路径语法新增 [*] 通配符(遍历所有元素)和 [N-M] 范围索引
- 新增 name_style action,支持 snake_case/camelCase/PascalCase/kebab-case/capitalize 风格转换
- condition 支持 $item.xxx 引用通配符当前元素,实现逐元素条件过滤
- 前端同步更新类型定义、表单 UI 和帮助文档
- 提取 isBodyRuleEffective 函数消除重复的规则有效性判断逻辑
2026-02-21 14:59:37 +08:00
fawney19 05177dffb3 修复数据库迁移脚本 2026-02-21 13:26:30 +08:00
fawney19 0fe5346f4d refactor: 提取 HandlerAdapterBase 基类,新增 api_family/endpoint_kind 结构化维度
- 从 ChatAdapterBase 和 CliAdapterBase 提取公共逻辑到 HandlerAdapterBase,
  消除头部处理、异常处理、计费策略等重复代码
- Usage 记录链路全程透传 api_family / endpoint_kind / provider_api_family /
  provider_endpoint_kind 四个结构化字段,替代从 api_format 字符串解析
- 删除冗余的 ClaudeCliNormalizer、GeminiCliNormalizer、ClaudeCliResponseParser、
  GeminiCliResponseParser,改用 data_format_id 回退机制自动复用 Chat 版本
- build_endpoint_url 签名统一扩展 request_data / model_name 参数
- 新增 Alembic 迁移,含历史数据回填
2026-02-21 13:17:11 +08:00
fawney19 54988916e3 fix: 统一 OAuth 重复账号检查逻辑,活跃账号一律拒绝覆盖
移除 Kiro 活跃重复账号的特殊放行,所有 Provider 类型行为一致:
失效账号允许覆盖,活跃重复账号拒绝并提示已存在。
2026-02-21 03:58:26 +08:00
fawney19 a1d972419d feat: Codex 请求转换引入 patch_for_variant 快速路径,Kiro 重复账号允许覆盖
- FormatNormalizer 新增 patch_for_variant 可选方法,同格式 + variant 场景
  跳过 internal 往返,直接在原始请求体上做最小补丁
- OpenAICliNormalizer 实现 Codex variant 快速路径,registry 优先尝试
- Codex request_patching 补充 stream=true、parallel_tool_calls=true、
  移除 previous_response_id
- Kiro 重复账号从拒绝改为允许覆盖(用户重新导入同一账号场景)
- Kiro Key 命名增加 auth_method 后缀,提取 _build_kiro_key_name 辅助函数
- Kiro token refresh 错误日志从 debug 提升为 warning,响应体截取增至 500 字符
2026-02-21 03:53:29 +08:00
fawney19 457fe83a4f fix(openai_cli): 支持非 function 类型 tool 的透传还原,保留 prompt_cache_key 字段 2026-02-21 02:32:32 +08:00
fawney19 314e4a497d feat: 拆分 usage 记录的请求体/响应体为客户端侧与提供商侧
将 request_body/response_body 语义明确为客户端原始请求体和提供商原始响应体,
新增 provider_request_body(格式转换后发给提供商的请求体)和 client_response_body
(格式转换后返回给客户端的响应体),支持跨格式转换场景下分别查看两侧数据。

- 数据库新增 provider_request_body/client_response_body 及对应压缩字段
- 全链路(telemetry/recording/handler/stream_context)传递新字段
- 维护调度器同步支持新字段的压缩与清理
- 前端请求详情抽屉支持请求体/响应体/响应头的客户端/提供商视图切换
2026-02-21 01:56:28 +08:00
fawney19 4c5dac603f refactor: 将流式转换逐次 debug 日志替换为流结束时的汇总日志 2026-02-20 22:04:46 +08:00
fawney19 d1724caab4 refactor: 引入 ExportMixin 统一模型导出逻辑,修复配置获取与 thinking 检测
- 新增 ExportMixin,基于排除列表自动收集可导出字段,应用于 Provider/Endpoint/GlobalModel/Model/ProviderAPIKey
- 修复系统配置获取:当 key 存在默认值时不再抛出 404
- 导出配置时增加 API Key / auth_config 解密失败的日志警告
- 修复 Gemini normalizer 的 thinking 检测,优先读取 internal.thinking 标准路径
2026-02-20 21:37:40 +08:00
fawney19 37758c2032 refactor: 完善跨格式 normalizer 转换精度,统一 CLI 流式 buffer flush 逻辑
- OpenAI normalizer: 修正 file/image 内容块的标准格式解析与输出,
  assistant 有 tool_calls 时 content 输出 null,非流式 tool_calls
  不再包含 index 字段,保留 system_fingerprint/service_tier roundtrip
- OpenAI CLI normalizer: 支持 reasoning/ThinkingConfig 双向转换,
  补全 parallel_tool_calls/required tool_choice,input_file 解析,
  function_call_output.output 强制字符串化,流式事件补全 item_id/
  output_index/content_index 字段
- Gemini normalizer: 扩展 finishReason 映射,自动检测 tool_use
  stop_reason,保留 safetySettings/cachedContent/generationConfig
  额外字段 roundtrip,error_from_internal 使用精确 HTTP status code
- Claude normalizer: 修正 tool_choice type="tool" 输出,扩展
  extra 提取白名单
- internal.py: 为所有 dataclass 补充跨格式映射文档和修改须知
- stream_bridge: aggregator 新增 open_count/final_count 诊断属性,
  build() 时 flush 未关闭的 open blocks
- CLI handler: 提取 _flush_buffer_with_conversion 统一 prefetch/
  stream 两条路径的 buffer + SSE parser flush 逻辑
- upstream_stream_bridge: 新增事件类型计数和聚合器状态诊断日志
- 新增 fixtures 和测试: roundtrip/to_internal/cross_format/error/
  stream 等多维度转换测试
2026-02-20 19:45:14 +08:00
fawney19 d7f0a555c0 fix(openai_cli): 完善 Responses API 响应格式,对齐 CLI 客户端协议要求
- 流式事件添加 sequence_number 递增序号
- output_text 内容补充 annotations 空数组
- 补充 content_part.added/done 和 function_call_arguments.done 事件
- 非流式响应回显原始请求字段(instructions/tools/reasoning 等)
- 补充 input_tokens_details/output_tokens_details 用量明细
- 响应结构添加 background/error/incomplete_details 等缺失字段
2026-02-19 22:43:04 +08:00
fawney19 5629edf487 refactor: 引入模块钩子系统,解耦认证逻辑,支持模块/normalizer/parser 自动发现
- 新增 HookDispatcher 钩子分发器,支持 FIRST_RESULT 和 COLLECT_ALL 两种策略
- LDAP 认证逻辑从 AuthService 移至 ldap 模块钩子实现
- Management Token 前缀认证从 pipeline 硬编码改为模块钩子注册
- src/modules/ 改为自动扫描子目录发现 ModuleDefinition
- normalizers 和 parsers 注册改为基于类属性自动发现
- OpenAI CLI 增加 /v1/responses/compact 端点和并行 tool_call 支持
- OpenAI CLI normalizer 支持 Chat Completions 格式自动回退
- Codex 适配器增加 compact 模式上下文传递和 header 调整
- HeaderBuilder 改进非 latin-1 字符处理(UTF-8 字节透传)
- Gunicorn 增加 graceful_timeout 防止僵尸进程
2026-02-19 21:26:18 +08:00
fawney19 7a81e56553 refactor: 统一 API 格式显示函数,补全 Usage/Trace 的 provider 链路信息
- 提取 formatApiFormat 为共享工具函数,替换各组件中分散的 API_FORMAT_LABELS 调用
- Trace API 返回密钥认证类型(key_auth_type)和 OAuth 套餐信息(key_oauth_plan_type)
- 请求时间线展示密钥认证方式标签(OAuth/Vertex AI/Kiro 等)
- Usage 记录补充 provider_id/endpoint_id/key_id,避免 curl 复现时缺失 provider 信息
- curl 复现兜底从 RequestCandidate 表查找 provider 信息
- Headers Diff 面板左右独立滚动并同步垂直滚动位置
2026-02-19 14:51:08 +08:00
fawney19 0d2cafaec3 refactor: 增强跨格式转换系统,支持 thinking/文件/音频/响应格式等完整转换
- 新增 ThinkingConfig/ResponseFormatConfig/FileBlock/AudioBlock 内部表示
- 实现 OpenAI reasoning_effort <-> Claude thinking <-> Gemini thinkingConfig 互转
- 支持 OpenAI web_search_options -> Claude web_search tool 转换
- 新增异步 convert_request_async,在转换阶段解析图片 URL 为 base64
- 将 GlobalModel.output_limit 传播至跨格式转换用于 max_tokens 默认值
- 前端模型表单增加最大输出 Token 和上下文窗口配置
- Claude normalizer 保留 cache_control 透传(system 数组和 content block)
- Gemini normalizer 支持内置工具(googleSearch/codeExecution/urlContext)
- OpenAI normalizer 补全采样参数、response_format、usage details 转换
- 各 normalizer 工具方法提取至基类消除重复代码
- 简化 Kiro model mapping 为直接透传
- 预置模型列表添加 claude-sonnet-4.6
2026-02-19 10:40:49 +08:00
fawney19 11f2ddbba2 chore: 将 Gunicorn workers 默认值从 4 调整为 2 2026-02-18 18:36:31 +08:00
fawney19 34f4cca1d2 fix(guide): 移除 URL 输入框多余的提示文字 2026-02-18 18:17:56 +08:00
fawney19 18b1dea8cf refactor(guide): 重构教程文档页面结构和布局
- 将 ProviderGuide/ModelGuide/UserKeyGuide 替换为 ArchitectureGuide/ConceptsGuide/StrategyGuide
- GuideLayout 迁移至 AppShell 组件,统一侧边栏导航和移动端菜单
- Overview 页面重写内容结构
- 更新路由配置和导航配置项
- homeGuard 添加 /guide 路径支持
2026-02-18 18:17:14 +08:00
fawney19 63870931af refactor: 调度器迁移至独立模块,消除 services->api 反向依赖
- 将调度器相关模块从 src/services/cache/ 迁移到 src/services/scheduling/
- 下沉类型定义到 core 层: AccessRestrictions, ProviderAuthInfo, ParsedChunk/StreamStats, 视频工具函数
- 提取 thinking_cache 签名缓存到 core/api_format/conversion/
- 提取 provider 认证逻辑到 services/provider/auth
- 提取遥测记录到 services/usage/telemetry
- 提取 models 列表缓存到 services/cache/model_list_cache
- 更新所有引用方的 import 路径及相关测试
2026-02-16 11:00:48 +08:00
fawney19andAAEE86 4dc401677d fix(system-settings): 修复定时任务时间选择器截断及添加取消按钮
Co-Authored-By: AAEE86 <[email protected]>
2026-02-15 20:19:35 +08:00
fawney19 25046d7c98 feat(system-settings): 添加右侧悬浮目录导航
为系统设置页面添加 TOC 侧边栏,支持点击跳转和滚动高亮,
使用 IntersectionObserver 实现滚动监听,圆点指示器标记当前章节。

Co-authored-by: AAEE86 <[email protected]>
Closes #177
2026-02-15 19:44:03 +08:00
fawney19 a7bc7f25b4 refactor: 引入 ExportMixin 统一配置导出,补全导入缺失字段
- 新增 ExportMixin 基于排除列表自动收集可导出列,新增字段无需修改导出代码
- GlobalModel/Model/Provider/ProviderEndpoint/ProviderAPIKey 混入 ExportMixin
- 导出逻辑改用 to_export_dict(),消除 GlobalModel N+1 查询
- 导入逻辑补全 provider_type、auth_config、body_rules、format_acceptance_config 等字段
2026-02-15 17:14:14 +08:00
fawney19 1c16b77a92 refactor: 拆分调度器为独立子模块,增强 Sub2API 多认证方式支持
调度器重构:
- 将 CacheAwareScheduler 拆分为 candidate_builder、candidate_sorter、
  concurrency_checker、restriction_checker、scheduling_config、schemas、utils 等独立模块
- 删除旧的 _candidate_builder.py 和 _candidate_sorter.py
- 新增调度并发拒绝 Prometheus 指标

Sub2API 架构增强:
- 支持账号密码登录和 Refresh Token 两种认证方式
- 实现 JWT 自动刷新和 Token Rotation 持久化
- 前端 ProviderAuthDialog 支持多认证方式切换和 credentials_schema 动态渲染
- 验证接口返回 updated_credentials 以同步轮换后的 token

其他改进:
- 并发管理器增加 RPM guard 和动态预留逻辑
- RequestCandidate 支持 mark_skipped 附加 extra_data
- TaskService 增强健壮性
- 补充相关单元测试和契约测试
2026-02-15 16:32:23 +08:00
fawney19 8a670f5524 refactor: 继续拆分大型模块并增强模块注册健壮性
后端:
- chat_handler_base 错误处理函数提取到 chat_error_utils 子模块
- CLI mixin 引入 CliHandlerProtocol 协议类改善类型标注
- aware_scheduler 拆分为 _candidate_builder 和 _candidate_sorter 子模块
- usage recording 拆分为 _billing_integration 和 _recording_helpers 子模块
- ModuleRegistry 添加循环依赖检测,将写操作从查询方法分离到 reconcile_module_state
- 修正 plugin manager 入度注释

前端:
- 路由守卫逻辑拆分为独立 guards 模块
- ProviderManagement 拆分为 TableHeader/TableRow/BalanceCell/MobileCard 子组件
- SystemSettings 拆分为多个 Section 子组件和 composables
- 提取 useEndpointStatus/useProviderBalance/useProviderFilters composables

测试适配重构后的子模块结构
2026-02-14 20:06:38 +08:00
fawney19 676e918edc feat(provider-ui): 支持供应商备注内联编辑
在供应商列表中添加备注的内联编辑功能,点击备注文字或"添加备注"
即可直接编辑,支持 Enter 保存、Escape 取消和点击外部自动取消。
桌面端和移动端均已适配。
2026-02-14 17:34:44 +08:00
fawney19 6ea33c6bb8 refactor: 拆分职责、引入 dataclass 封装并增强缓存健壮性
- ErrorClassifier 副作用操作分离为 ErrorHandlerService(缓存失效、健康记录、RPM 调整)
- chat_handler_base 提取 ProviderRequestResult dataclass 和 _prepare_provider_request 方法
- failover 提取 AttemptErrorOutcome dataclass 和辅助方法
- formula_engine 拆分 _resolve_mapping 为子方法,增加求值异常日志
- usage recording 引入 UsageCostInfo dataclass 封装成本参数
- 前端 types.ts 拆分为 types/ 子模块
- cache backend 工厂函数加锁防止并发重复创建,LocalCache 容量检查修正
- CacheSync 监听增加断线重连机制,publish 增加重试
- guide 页面修正 useSiteInfo() 调用顺序
2026-02-14 16:34:52 +08:00
fawney19 26ede849e2 refactor: 拆分大型模块为 mixin/子模块结构
- cli_handler_base.py 拆分为 7 个 mixin (event/monitor/prefetch/request/sse_helpers/stream/sync)
- usage/service.py 拆分为 6 个子模块 (types/active_requests/cache_analysis/lifecycle/pricing/query/recording)
- models/database 拆分为独立模型文件 (auth/misc/model/provider/stats/usage/user)
- DUMMY_THOUGHT_SIGNATURE 常量提升到 core/api_format/conversion/constants 统一管理
- task/service.py 内联导入提升为顶层导入
- 流处理函数签名移除冗余的 http_client 参数
2026-02-14 12:55:58 +08:00
fawney19andLewisPen f464f32e48 feat: 支持系统设置中自定义全站名称和副标题
新增 site_name 和 site_subtitle 两个系统配置项,允许管理员在后台自定义
站点品牌名称(默认 Aether)和副标题(默认 AI Gateway)。

- 后端:DEFAULT_CONFIGS 新增配置项,公开 /api/public/site-info 端点
- 后端:邮件发送 app_name 回退链增加 site_name
- 前端:新增 useSiteInfo composable 全局缓存站点信息
- 前端:首页、后台布局、登录页、指南页面全部改为动态读取
- 前端:系统设置页新增「站点信息」配置区块
- 前端:配置导出文件名跟随站点名称
- 优化:请求失败允许重试,保存后即时生效无需刷新页面
- 优化:document.title 随站点名称动态更新

Closes #176

Co-authored-by: LewisPen <[email protected]>
2026-02-13 21:50:05 +08:00
fawney19 c171d65d3f Merge pull request #175 from AAEE86/dev
fix: Kiro 导入授权时无法判断重复
2026-02-13 21:31:06 +08:00
fawney19 d01caaa4b1 refactor(provider-ui): 调整供应商信息布局,网站和描述展示方式互换
- 详情抽屉:移除描述内联编辑,网站地址改为独占整行显示完整 URL
- 列表/卡片视图:网站改为名称旁的 ExternalLink 图标,新增描述文本展示
- 移除不再使用的 formatWebsiteDisplay 工具函数和描述编辑相关代码
2026-02-13 21:27:30 +08:00
fawney19 941e599d36 feat(provider-ops): 新增 Sub2API 架构支持并优化余额分项显示
- 新增 Sub2ApiArchitecture 和 Sub2ApiBalanceAction,支持 Sub2API 风格中转站
- 前端 provider 列表支持 balance + points 分项显示
- 验证弹窗适配 Sub2API 余额和积分的分开展示
2026-02-13 21:03:21 +08:00
fawney19 d6b39babf5 refactor(provider-ops): 重构认证配置为 schema-driven 模式
后端架构类通过 get_credentials_schema() 返回带 x-* 扩展字段的 JSON Schema,
前端根据 schema 动态渲染表单、构建请求、验证和格式化显示。
新增架构只需后端一个文件,前端零改动。

主要变更:
- 删除前端手写模板文件(anyrouter.ts, cubence.ts, nekocode.ts, new-api.ts, yescode.ts)
- 新增 schema-utils.ts 实现 schema 到表单的转换、请求构建、验证和格式化
- 新增 field-hooks.ts 支持 schema 声明式字段联动钩子
- 后端 base.py 提供 parse_verify_response 默认实现,减少子类重复代码
- 各架构余额查询逻辑复用 balance.py 中的通用函数
- 删除废弃的 one_api.py 架构,新增架构 hidden 属性
- API 返回 credentials_schema 供前端消费
2026-02-13 16:41:30 +08:00
AAEE86 3f68821663 fix: Kiro 导入授权时无法判断重复
- 将 _fetch_kiro_email() 移到 _check_duplicate_oauth_account() 之前
2026-02-13 14:43:23 +08:00
fawney19 7e68882872 style: 修正 PR #174 引入的 import 排序问题 2026-02-13 14:18:51 +08:00
fawney19 e06f6c1935 Merge pull request #174 from hemo94931/dev
fix(conversion): 修复 openai cli tool 调用空 call_id 导致上游失败
2026-02-13 14:17:48 +08:00
fawney19andLewisPen 599a62d722 fix: 补充 OpenAI CLI normalizer 图片格式双向转换
- 将 _image_url_to_block 从 OpenAINormalizer 提升到基类 FormatNormalizer
- OpenAI CLI normalizer 新增 ImageBlock 的解析和输出支持
- 修复 Chat -> CLI 跨格式路由时图片内容丢失

Co-Authored-By: LewisPen <[email protected]>
2026-02-13 13:59:57 +08:00
fawney19 d690dcadc7 refactor: 修复候选分页逻辑并拆分配额检查模块
- list_all_candidates 返回 provider_batch_count,区分"无候选"与"无 Provider"
  避免分页在有 Provider 但无候选时提前终止
- 将配额检查逻辑从 aware_scheduler.py 拆分到 quota_skipper.py
- 提取 reorder_candidates 方法,支持跨页汇总后全局重排序
- CandidateResolver 分页循环改用 provider_batch_count 判断终止
- candidate extra_data 中新增 mapping_matched_model 字段
- 新增契约测试和分页行为测试
2026-02-13 13:28:40 +08:00
hemo94931 e80bcabccf fix(conversion): 修复 openai cli tool 调用空 call_id 导致上游失败
- 在 FormatConversionRegistry.convert_request 前统一修复 InternalRequest 中空的 tool_id/tool_use_id

- 为空 ID 自动生成 call_auto_N,并将 tool_result 关联到最近待匹配的 tool_call

- 修正 OpenAICliNormalizer 中 function_call_output 的内部角色为 USER,确保输出 openai:chat 时保留 tool_call_id

- 新增 openai:cli <-> openai:chat 空 call_id 回归测试,覆盖工具调用关联场景
2026-02-13 12:32:29 +08:00
fawney19 4c83780b5e chore: 调整 Nginx 代理超时配置并移除废弃的 concurrency_slot_ttl
- proxy_connect_timeout 从 600s 降至 60s
- proxy_send_timeout/proxy_read_timeout 从 600s 升至 3600s 以支持长时间流式响应
- 移除已无引用的 concurrency_slot_ttl 配置项
2026-02-12 11:57:42 +08:00
fawney19 be430ebdde perf: 降低 lru_cache 上限并限制流式响应块内存占用
- 缩减 model_permissions / tiktoken / formula_engine 的 lru_cache maxsize
- StreamUsageTracker 响应块增加 4MB 大小限制,超限后只计数不存储
- raw_chunks 改用 deque(maxlen=50) 避免无限增长
- HardwareTooltip 导入路径修正、tooltip 延迟归零、文案中文化
2026-02-12 11:41:42 +08:00
fawney19andAAEE86 483d536e2c perf: cherry-pick PR #172 性能优化(不含 orjson)
- tiktoken 编码器改为 @lru_cache 全局缓存,避免多实例重复初始化
- 前缀匹配按长度排序,避免短前缀抢先匹配
- AuthService 更新 last_used_at 时临时关闭 expire_on_commit,减少额外 SELECT
- UsageService.record_usage_batch 改用 bulk_insert_mappings 批量插入
- 已有记录查询增加 selectinload 预加载,避免 N+1
- Gemini normalizer 长行格式化

Co-Authored-By: AAEE86 <[email protected]>
2026-02-12 10:58:14 +08:00
fawney19 f80deea110 chore: bump aether-proxy version to 0.1.5 2026-02-11 23:35:23 +08:00
fawney19 6956536830 refactor: 将配置导入导出常量移至文件顶部 2026-02-11 23:27:48 +08:00
fawney19 01c725a2ee Merge pull request #170 from RWDai/fix-config-version
fix: 配置导入导出版本号统一常量管理,移除前端硬编码版本校验
2026-02-11 23:26:33 +08:00
fawney19 1c2a8119c0 feat: delegate 客户端替换为 hyper 原生实现,新增代理管理功能
aether-proxy:
- 用 hyper-util Client + 自定义 InstrumentedConnector 替换 reqwest delegate 客户端
- 支持 HTTP/HTTPS 自动 TLS,ALPN h2 协商,connect/tls 分阶段计时
- ConnectTiming 通过 hyper extensions 传递,上游响应细分 connect_ms/tls_ms/ttfb_ms
- upgrade 命令在非 root 下跳过 systemd restart 并提示手动操作

后端:
- 新增 /admin/proxy-nodes/test-url 接口,支持直接测试代理 URL 连通性
- 新增 /admin/proxy-nodes/hmac-key 接口,获取 HMAC Key 供部署使用
- 提取 _test_proxy_connectivity 公共函数,消除 test_node 中的重复代码
- candidate_resolver 在 extra_data 中输出 needs_conversion/provider_api_format
- stats_aggregator 小时聚合增加 IntegrityError 冲突重试

前端:
- 请求时间线组件展示代理 timing 细分(DNS/连接/TLS/TTFB/上游处理)
- 请求时间线增加格式转换分界标记和 conversion badge
- ProxyNodes 页面新增代理 URL 测试和 HMAC Key 复制功能
- HardwareTooltip 从 Popover 改为 Tooltip 组件
2026-02-11 23:24:59 +08:00
fawney19 943ca79951 chore(aether-proxy): bump version to 0.1.4 2026-02-11 17:29:37 +08:00
fawney19 62c05093a0 fix(docker): 统一 max-requests 默认值为 4000
PR #171 中 max-requests 的 fallback 默认值 (2000) 与 jitter 计算的默认值 (4000) 不一致,统一为 4000
2026-02-11 17:26:22 +08:00
fawney19 d71d4e24be Merge pull request #171 from AAEE86/dev
refactor(docker): 优化 Gunicorn 配置并提升代码可读性
2026-02-11 17:21:19 +08:00
fawney19 37e0785775 fix(frontend): HardwareTooltip 组件健壮性重构
- hardware_info 支持 string/object/null 类型的安全解析
- 用 computed 统一提取展示行,无硬件信息时显示提示文案
- 增加 button 的 title/aria-label 属性
2026-02-11 17:17:53 +08:00
fawney19 0ddeccf698 feat(aether-proxy): delegate gzip body 流式透传替代读取解压
- gzip 压缩请求体直接流式转发给上游,避免全量读取和解压
- 非 gzip 请求保持原有读取逻辑
- 移除 body_read_ms/decompress_ms 指标,新增 passthrough 标记
- 前端 timing 展示兼容旧版字段
- 同步更新 Cargo.lock
2026-02-11 17:17:45 +08:00
AAEE86 875ecca527 refactor(docker): 优化 Gunicorn 配置并提升代码可读性
使 Gunicorn 的 max-requests 参数可通过环境变量 MAX_REQUESTS 配置,
并自动计算 max-requests-jitter (默认为 MAX_REQUESTS 的 1/20),
同时统一规范化 Dockerfile 中多行字符串的缩进格式
2026-02-11 17:14:32 +08:00
RWDai 3a5d687f08 fix: 配置导入导出版本号统一常量管理,移除前端硬编码版本校验 2026-02-11 17:06:06 +08:00
fawney19 257077a59b chore: bump aether-proxy version to 0.1.3 2026-02-11 16:39:07 +08:00
fawney19 9647d95759 feat: delegate 协议改为 header 元数据 + gzip 压缩 body 直传
- delegate wire format 从 JSON body 改为 HTTP headers 传递元数据
  (X-Delegate-Method/Url/Headers),上游请求体 gzip 压缩后直传,
  减少跨国代理传输耗时
- Rust 侧新增 gzip 解压(含 decompression bomb 防护 50MB 上限)
- 升级模块从 GitHub API asset 下载改为公开 release URL 直链,
  GITHUB_TOKEN 变为可选
- 前端适配新 timing 字段,展示压缩率与 wire_size/body_size
- CI release notes 改用 generate_release_notes 自动生成
2026-02-11 16:34:18 +08:00
fawney19 2436ce45a2 chore: bump aether-proxy version to 0.1.2 2026-02-11 15:35:28 +08:00
fawney19 2a2a4b817e feat: 代理 timing 细分指标与前端展示优化
- aether-proxy 新增 auth_ms/body_read_ms/body_parse_ms/body_size 计时字段
- 链路追踪面板代理耗时改为分阶段展示,密钥和代理信息改为堆叠布局
- 硬件信息从 Tooltip 改为 Popover,新增 FD Limit 展示
- 新增区域代码格式化工具函数,统一显示区域中文名称
2026-02-11 15:33:01 +08:00
fawney19 d51234f202 fix(frontend): 链路追踪打开时自动选中进行中的尝试而非最后一个未执行的 2026-02-11 13:35:02 +08:00
fawney19 7abe7f1a87 chore: 更新 README 链接与清理 generate_keys 冗余输出
- 修正 aether-proxy README 中 Releases 为完整 URL,优化描述文案
- 移除 generate_keys.py 中不再需要的 hmac_key 配置提示
2026-02-11 13:24:28 +08:00
fawney19 2849a9bce5 fix(frontend): 密钥列表分页控件固定在底部 2026-02-11 12:57:52 +08:00
fawney19 2aee8b8c2c Merge pull request #169 from AAEE86/master
feat: 调度器根据上游配额自动跳过已耗尽的 Key (Kiro/Codex/Antigravity)
2026-02-11 12:55:52 +08:00
AAEE86 e77c28b89b feat: 调度器根据上游配额自动跳过已耗尽的 Key (Kiro/Codex/Antigravity)
在 _check_key_availability 末尾增加 _is_key_quota_exhausted 检查,读取 ProviderAPIKey.upstream_metadata 中各 Provider 的配额信息,配额耗尽时跳过该 Key 并返回可读的跳过原因。同时修正同函数内 logger 调用为 loguru {} 占位符风格。
2026-02-11 12:53:40 +08:00
fawney19 ed3f208dda feat(aether-proxy): 网络层全面优化与代理耗时追踪
aether-proxy:
- 新增 20+ 可配置参数:Aether API/delegate/CONNECT 各阶段超时、连接池、TCP keepalive/nodelay
- AetherClient 支持指数退避重试(可配置次数/延迟)和 HTTP/2
- 新增 DNS 缓存(TTL + 容量限制)避免重复解析
- 新增并发连接数限制(Semaphore,默认基于硬件估算)
- CONNECT 隧道增加建连超时和升级超时
- TLS 增加握手超时、会话缓存(session ticket + memory cache)、ALPN 协商
- 新增 ProxyMetrics 收集请求计数和延迟,通过心跳上报
- delegate 响应注入 X-Proxy-Timing 头(dns_ms/upstream_ms/total_ms)

后端:
- StreamContext 和 handler 提取 X-Proxy-Timing 写入 proxy_info 追踪数据

前端:
- 请求时间线展示代理分阶段耗时(DNS/上游)
- Endpoint 添加按钮改为文字按钮样式
2026-02-11 11:30:29 +08:00
fawney19 68f5e7e502 fix(frontend): 链路追踪打开时自动选中有效状态的节点
修复 Provider 组状态提升逻辑,之前仅在 success 时更新组状态,
导致组内有 streaming/failed 等状态的 key 时组仍显示为 available,
自动选择逻辑无法识别有效节点。引入状态优先级机制正确提升组状态,
并扩大 fallback 有效状态范围,避免默认选中未执行的节点。
2026-02-11 10:06:59 +08:00
fawney19 11b0026995 feat: OAuth 重复账号失效时允许覆盖更新,优化前端分页与刷新体验
- OAuth 去重逻辑改为:已失效的重复账号自动覆盖更新而非拒绝,
  适用于单个导入、批量导入、Kiro 导入等所有入口
- 新增 _update_existing_oauth_key 函数统一处理覆盖更新逻辑
- useSmartPagination 新增 fixedHeight 属性防止翻页时容器高度跳动
- ProviderDetailDrawer OAuth 刷新后仅重载 keys 数据避免整表刷新
2026-02-11 10:01:28 +08:00
fawney19 5cfae4f8f0 Merge pull request #168 from AAEE86/master
refactor(frontend): 优化模型/提供商组件的数据刷新逻辑
2026-02-11 09:20:35 +08:00
fawney19 bda03d187e refactor: 统一代理配置优先级链(key>provider>系统默认)并复用 HTTP 连接池
- 引入 resolve_proxy_param / build_proxy_client_kwargs 工具函数,统一
  httpx 客户端的代理+SSL+超时配置,替换各模块中零散的 get_ssl_context() 调用
- 所有涉及上游请求的模块(provider_query, usage replay, endpoint check,
  model fetch, OAuth, Vertex Auth, Gemini Files/Video 等)改用
  resolve_effective_proxy 按 key > provider > 系统默认优先级解析代理
- 流式请求改用 HTTPClientPool.get_upstream_client 复用连接池,移除各处
  http_client.aclose() 避免关闭共享客户端
- StreamProcessor._cleanup 不再关闭池中客户端,仅清理响应上下文
- 前端 EndpointFormDialog 增加 body_rules 帮助说明 Popover
- Mock handler 补充 OAuth 字段、endpoint extras 及新增 mock 路由
2026-02-11 03:16:53 +08:00
AAEE86 0fa15604dc refactor(frontend): 优化模型/提供商组件的数据刷新逻辑
- ModelDetailDrawer: 移除 handleMappingsUpdate 中冗余的 refreshRoutingData 调用,路由刷新改由 @refresh 事件统一处理
- ModelMappingsTab: saveMappings 成功后始终 emit update 和 refresh 事件,确保数据一致性
- BatchAssignModelsDialog: 为批量添加/导入操作增加 try-catch 错误处理,部分操作失败时仍通知父组件刷新
- ProviderDetailDrawer: 移除 modelsTabRef 直接调用子组件 reload 的模式,统一通过 emit('refresh') 由父组件协调刷新
2026-02-11 03:09:26 +08:00
fawney19 cd0acf1e6b fix: OAuth 账号去重改用 user_id 替代 account_id,避免同 team 成员误判重复 2026-02-11 01:13:01 +08:00
fawney19 9e183a2033 Merge pull request #167 from AAEE86/dev
feat: Codex/Kiro 配额自动刷新增加 5 分钟过期检查
2026-02-11 01:10:19 +08:00
AAEE86 76e8e48400 fix(frontend): 移除 Dashboard onMounted 中重复的 daily-stats 请求
TimeRangePicker 初始化时已通过 watch immediate 触发数据加载,
无需在 onMounted 中重复调用 loadDailyStats()
2026-02-11 01:03:09 +08:00
fawney19 3d6d4a48a5 feat: body_rules 支持 condition 条件触发
为每条 body_rule 新增可选 condition 字段,支持 eq/neq/gt/lt/gte/lte/
starts_with/ends_with/contains/matches/exists/not_exists/in/type_is
共 14 种操作符,规则仅在条件满足时执行。

后端: 新增 _evaluate_condition 条件评估器与 _validate_condition 校验逻辑
前端: EndpointFormDialog 增加条件编辑行(IF 面板)与 Filter 按钮切换
测试: 新增 TestConditionalBodyRules 覆盖全部操作符及链式触发场景
2026-02-11 00:50:08 +08:00
AAEE86 5d0240e675 fix(frontend): TimeRangePicker 防止重复触发 update:modelValue
- 添加 lastEmittedValue 记录上次 emit 的值
- 提取 buildEmitValue 构建 emit 数据
- 使用 getValueKey 比较核心字段,忽略 timezone 等动态值
- 外部设置值时同步更新 lastEmittedValue 避免回环触发
2026-02-11 00:37:15 +08:00
AAEE86 cabaa9f1df refactor(frontend): 统一 routing 数据加载,消除子组件重复请求
将 Model 和 Provider 详情抽屉中的 routing/mapping 数据加载逻辑
从各子组件(RoutingTab、ModelMappingsTab、ModelsTab、ModelMappingTab)
提升到父组件统一管理,通过 props 向下传递数据,避免多个子组件独立发起相同的 API 请求,提升页面加载性能。
2026-02-10 23:47:47 +08:00
AAEE86 347bd3345c feat: Codex/Kiro 配额自动刷新增加 5 分钟过期检查
- Codex: 新增 Token 即将过期检查 + 配额数据过期检查
- Kiro: 新增配额数据过期检查
- 三个 OAuth Provider 的自动刷新逻辑现在完全一致
2026-02-10 21:12:31 +08:00
fawney19 262bc6e1f7 feat: body_rules set 操作支持 {{$original}} 占位符引用原值
后端 request_builder 新增递归检测与解析逻辑,完全匹配时保留原始类型,
部分匹配时转为字符串拼接。前端 EndpointFormDialog 增加 sentinel 替换
机制,使含占位符的值能通过 JSON 校验,并在提交时还原为 {{$original}}。
2026-02-10 21:01:45 +08:00
fawney19 e06152b58b Merge pull request #166 from AAEE86/dev
fix: Claude SSE 流式输出补充 event 类型行
2026-02-10 18:56:21 +08:00
AAEE86 d8addec077 fix: Claude SSE 流式输出补充 event 类型行 2026-02-10 18:42:50 +08:00
fawney19 5456596b29 style: 优先级管理弹窗间距与样式微调 2026-02-10 18:32:51 +08:00
fawney19andAAEE86 8949ad6d9e feat: 动态 block 索引分配、OAuth 自动启停联动与 Antigravity 签名注入增强
- Gemini/OpenAI normalizer 改为按实际出现顺序延迟分配 thinking/text block 索引
- OAuth 失效标记时自动停用 Key,清除/刷新成功时自动启用
- Antigravity thought signature 注入支持 tool-specific 签名优先,再回退 session 级签名
- Claude normalizer 对非字符串 thinking block 降级为 UnknownBlock
- Gemini normalizer 反序列化时为 Antigravity 目标补充 signature
- signature_cache _prune 增加超限驱逐
- model/provider 包 __init__ 改为惰性导入避免循环依赖
- build_antigravity_url 复制 query_params 防止修改调用方原始字典
- upstream_fetcher build_all_format_configs 兼容非 EndpointFetchConfig 对象
- Antigravity HTTP 状态判定扩展与 Gemini endpoint check 支持 v1internal
- 新增 thought signature 注入与 signature cache 驱逐测试

Closes #165

Co-authored-by: AAEE86 <[email protected]>
2026-02-10 17:53:04 +08:00
fawney19 0bb17a1502 chore: Docker 基础镜像从 Python 3.14 降级到 3.13 2026-02-10 17:41:36 +08:00
fawney19 0c52341dec feat: TTFB 追踪记录与前端展示 + 缓存调度器代码整理
- CLI handler 记录上游请求 TTFB 并注入 proxy_info
- 前端请求时间线展示 TTFB 耗时
- 调度器提升局部 import 到模块顶部,提取 _affinity_hash/_merge_restriction_sets 辅助方法
- 修复缓存亲和性写入的缩进层级
- Python 版本回退至 3.13
2026-02-10 17:37:53 +08:00
fawney19 4276e7835c refactor: 自适应 RPM 从边界记忆改为多次观察确认 + 置信度衰减
将自适应 RPM 限制算法从"单次 429 即设限"重构为基于置信度的学习机制:

- 收到 429 时仅记录观察(本地 RPM + 上游 header 限制值),不立即设限
- 有 header 的观察需 2 次一致确认,无 header 需 3 次一致确认
- confidence 随时间自然衰减,确保限制永远不会固化
- confidence 低于阈值时停止本地 RPM 限制,429 直接透传客户端
- 统一 get_effective_limit 入口,消除多处重复的限制获取逻辑
- Admin API 增加置信度、观察数量等诊断字段
2026-02-10 14:58:15 +08:00
fawney19 fc1ad44346 feat: Antigravity 上游模型按 tier 排序逐个获取与前端单 Key 查询
- 后端 Antigravity Provider 获取上游模型改为按 tier/可用性排序后逐个尝试,成功即停止
- 新增 Provider 级别缓存避免重复请求
- 前端编辑 Key 允许模型时改为传入当前 Key ID 查询对应可用模型
2026-02-10 11:24:22 +08:00
fawney19 91cf93d133 fix: Antigravity function_call/function_response 驼峰转换与 thinking budget 非正值过滤
Close #161
2026-02-10 10:34:28 +08:00
fawney19 457de0919a Merge pull request #163 from AAEE86/master
fix: Antigravity 跨格式转换链 tool call ID 完整传递
2026-02-10 10:27:43 +08:00
AAEE86 d4fd3518bf fix: Antigravity Claude tool ID 匹配与 thinking budget 输出约束
- 重构 _inject_claude_tool_ids_request 采用两遍扫描算法:
  * 第一遍收集所有 functionCall 的 ID(按 name 分组)
  * 第二遍为 functionResponse 从对应队列中取出匹配的 ID
  * 解决多工具调用场景下 ID 匹配错乱问题

- 增强 _process_thinking_budget 确保 maxOutputTokens > thinkingBudget:
  * 新增 OUTPUT_OVERHEAD/OUTPUT_OVERHEAD_IMAGE 常量(对齐 AM)
  * 自动调整 maxOutputTokens 为 budget + overhead
  * 超过 MODEL_MAX_OUTPUT_LIMIT 时减少 budget 而非超限
2026-02-10 05:12:21 +08:00
fawney19 f9adf5938e feat: 请求记录 cURL 导出与回放功能
- 后端新增 /{usage_id}/curl 接口,重建完整 cURL 命令(含明文 API Key)
- 后端新增 /{usage_id}/replay 接口,支持向原始或指定提供商回放请求
- 回放支持 OAuth/Vertex AI/API Key 多种认证方式和跨格式请求体转换
- 前端新增 ReplayDialog 组件,支持选择目标提供商/Key 并查看响应
- RequestDetailDrawer 添加回放按钮和 cURL 复制按钮
- 调整 Tab 内容区布局,表头栏与内容区融为一体
2026-02-10 03:07:49 +08:00
AAEE86 5d36fd7f99 fix: Antigravity 跨格式转换链 tool call ID 完整传递
修复通过 Antigravity 适配器发送请求到 Claude 模型时 tool_use 块缺少必需 id 字段的问题。

根本原因:在跨格式转换链 (Gemini → Internal → Claude/Gemini) 中,
functionCall 和 functionResponse 的 id 字段没有被正确传递。

修复内容:
- internal.py: ToolResultBlock 增加 tool_name 字段,区分工具名称和 call id
- gemini.py: 读取/输出 functionCall 和 functionResponse 时保留 id 字段
- gemini.py: 流式响应中正确传递 tool_id
- envelope.py: 同时支持 camelCase/snake_case 两种命名风格
2026-02-10 02:50:45 +08:00
fawney19 9f5dd6f658 feat: 跨格式 thinking/reasoning 透传与 Antigravity 适配器增强
- 内部表示层新增 ThinkingBlock,统一 Claude thinking / Gemini thought / OpenAI reasoning_content
- Claude/Gemini/OpenAI/OpenAI CLI normalizer 全面支持 thinking 内容的解析、流式处理和跨格式转换
- schema_utils 重写为完整的 JSON Schema 清洗逻辑($ref 展开、allOf 合并、anyOf 折叠、白名单过滤)
- Antigravity envelope 新增模型别名映射、Google Search 注入、thoughtSignature 注入、图像生成配置解析

Close #161
2026-02-10 02:05:35 +08:00
fawney19 de40bd4705 fix: Claude API 模型列表查询兼容 Bearer Token 认证
第三方代理可能使用 Bearer Token 而非 x-api-key 认证,
在请求头中同时发送两种认证方式以提高兼容性。
2026-02-10 01:01:05 +08:00
AAEE86 6c5c41e8ea fix: 修复 Antigravity beta 参数和 Kiro 403 状态处理
- Antigravity: 移除 v1internal 请求中不支持的 beta 查询参数
- Kiro: 将 403 状态码统一视为账户异常,而非仅检测特定关键词
2026-02-10 00:54:43 +08:00
AAEE86 bd9c264375 fix: 修复 Antigravity 反代 systemInstruction oneof 冲突
当客户端发送 snake_case 格式的 system_instruction 字段时,
_inject_system_instruction 函数会额外设置 camelCase 格式的
systemInstruction,导致 Gemini API 报错 oneof 字段冲突。

现在在注入前先统一 snake_case 到 camelCase,确保只有一个字段存在
2026-02-10 00:09:14 +08:00
fawney19 1d625916ff refactor: 模块管理支持内置工具展示
将邮件配置、IP 安全、审计日志等系统功能从固定菜单移至模块管理页面,以内置工具形式统一展示
2026-02-09 22:52:46 +08:00
fawney19 80d65ad2e4 fix: Claude CLI 模型列表使用正确的 Bearer 认证方式 2026-02-09 22:45:21 +08:00
fawney19 b11e9de6a7 feat: 全局模型新增活跃 Provider 数量统计
- 后端优化查询,使用条件聚合同时获取总 Provider 数和活跃数
- 前端展示格式改为「活跃数/总数」,便于了解可用 Provider 情况
2026-02-09 22:26:30 +08:00
AAEE86 7b28680cda fix: Kiro 导入支持区分 Social 和 IdC 登录方式
修改 _check_duplicate_oauth_account 函数的重复检查逻辑:
- Kiro 使用 email + auth_method 组合判断重复
- 同一邮箱通过 Social 和 IdC 两种方式登录视为不同账号
- 移除冗余的 profile_arn 单独检查
- 其他 OAuth Provider 保持原有逻辑(仅 email 判断)
2026-02-09 21:57:50 +08:00
fawney19 853c489471 refactor: 优化智能分页检测和密钥复制交互
- 智能分页统一使用防抖检测避免与 ResizeObserver 双重触发
- 密钥名称仅在非空时启用复制功能
- 移除 API Key 不必要的危险字符校验
2026-02-09 19:05:29 +08:00
fawney19 c6cd7d5ae6 Merge pull request #159 from AAEE86/master
refactor: 优化智能分页 DOM 检测机制并添加密钥名称复制功能
2026-02-09 19:04:19 +08:00
AAEE86 8e120e2665 refactor: 优化智能分页 DOM 检测机制并添加密钥名称复制功能
- useSmartPagination 使用 ResizeObserver 替代 nextTick 确保 DOM 稳定后检测
- 添加防抖机制避免频繁触发检测
- 组件卸载时清理 ResizeObserver 资源
- ProviderDetailDrawer 密钥名称支持点击复制
2026-02-09 19:01:14 +08:00
fawney19 46ff120ab2 fix: 优化 Alembic 迁移脚本的列存在性检查
修复端点数据迁移到 provider 时的 SQL 执行逻辑,动态检查列是否存在后再构建 UPDATE 语句,避免在列不存在时引用导致迁移失败。
2026-02-09 18:40:08 +08:00
fawney19 7f7f569148 refactor: 优化优先级管理和路由展示功能
- 支持按 API 格式设置不同的全局优先级,替换单一 global_priority 字段为 global_priority_by_format
- 路由标签根据调度模式动态显示(缓存亲和/负载均衡/固定顺序)
- 优先级管理对话框 UI 优化:紧凑布局、活跃/停用状态分组排序、快捷启用/禁用开关
- 优先级管理对话框包含所有 Key(含停用的),支持快速切换状态
- 优化配置更新流程:先保存优先级数据,再切换调度模式,避免瞬态不一致
- 调度相关配置缓存 TTL 从 60 秒降至 5 秒,确保多 Worker 快速收敛
- 配置更新时立即同步当前 Worker 的 Scheduler 状态
2026-02-09 18:21:34 +08:00
fawney19andAAEE86 b14fb31933 feat: 新增 Antigravity/Kiro 账户禁用封禁状态监控与展示
- 前端:为 Antigravity 和 Kiro 上游元数据添加禁用/封禁状态字段
- 前端:在密钥详情抽屉中展示账户禁用/封禁警告信息,合并重复的时间格式化函数
- 后端:优化 Antigravity 和 Kiro 适配器的状态检测与上报逻辑

Co-authored-by: AAEE86 <[email protected]>

Closes #158
2026-02-09 17:24:48 +08:00
fawney19 65658e58d5 refactor: 移除 API Key 最小长度限制并放宽最大长度至 10000 字符 2026-02-09 17:00:48 +08:00
fawney19 c5d1d2c21e fix: Nginx 白名单路由补充 v1beta 和 upload 路径前缀 2026-02-09 16:45:56 +08:00
fawney19 f22d2efb9d refactor: 优化 CI 缓存隔离与镜像标签策略,重构 Nginx 为白名单路由模式
- CI: 按 scope 隔离 base/app 构建缓存,app 层使用 no-cache-filters 确保前端每次重建
- CI: 镜像标签改为 semver 优先,支持 pre/fix 预发布标签,启用 latest=auto
- Nginx: 从 try_files + @backend 回退模式改为白名单路由(api/v1/health → 后端,其余 → SPA)
2026-02-09 16:04:54 +08:00
fawney19 d0b9dc7a24 feat: 非 custom 提供商新建密钥时默认开启自动获取上游模型
- 前端 KeyFormDialog 根据提供商类型自动设置 auto_fetch_models 默认值
- OAuth 创建密钥时传入 auto_fetch_models=True
- OAuth 完成、refresh token 导入、批量导入后自动触发模型获取
2026-02-09 13:50:27 +08:00
fawney19 0da34f729e refactor: 优化提供商管理界面交互与术语,增强 OAuth 编辑体验
- 模型映射对话框统一术语为"客户端模型/提供商模型",修复编辑时已有映射不可见的问题
- 密钥表单包含/排除规则改为双列布局
- OAuth 密钥编辑对话框增加 dirty 状态跟踪,关闭时提示未保存更改
- 调整密钥操作按钮顺序,代理节点配置移至编辑按钮前
- preset_models.py 添加 ModelsFetcherFunc 类型别名
2026-02-09 13:24:42 +08:00
fawney19 57f3ed71a1 Merge pull request #157 from AAEE86/master
feat: 新增 OAuth 密钥专用编辑对话框 & 统一预设模型管理
2026-02-09 13:02:13 +08:00
fawney19 34cdc26e6f fix: 修复候选调度中 priority 为 0 时被错误回退为默认值的问题
使用显式 is not None 判断替代 or 运算符,避免优先级为 0 时被当作 falsy 值处理
2026-02-09 12:58:27 +08:00
fawney19 8684548072 refactor: 用 EndpointFetchConfig 纯数据类替代 ORM 对象传递,统一上游模型缓存管理
- 引入 EndpointFetchConfig dataclass 替代直接传递 ProviderEndpoint ORM 对象,
  避免 DB session 关闭后 DetachedInstanceError
- 新增 build_format_to_config() 统一构建 api_format -> EndpointFetchConfig 映射
- KeyAllowedModelsDialog 改用 useUpstreamModelsCache composable 管理上游模型获取
- useUpstreamModelsCache 增加 error 字段透传部分格式获取失败的 warning
- 删除废弃的 queryProviderUpstreamModels API 函数
- ProviderCandidate 添加 __lt__ 方法支持排序比较
2026-02-09 12:47:47 +08:00
AAEE86 ea5509f864 feat: 新增 OAuth 密钥专用编辑对话框 & 统一预设模型管理
- 新增 OAuthKeyEditDialog 组件,支持编辑 OAuth 账号的名称、备注、优先级、RPM 限制、缓存 TTL、熔断探测等配置
- OAuth 账号现在也支持自动获取模型功能,包含模型过滤规则配置
- 移除 OAuth 密钥编辑/权限按钮的 v-if 限制,统一操作入口
- 新增 preset_models.py 模块,统一管理 Kiro/Codex 等无 /v1/models 端点的预设模型
- 重构 Kiro 和 Codex 插件,改用统一的 create_preset_models_fetcher 工厂函数
2026-02-09 12:44:32 +08:00
fawney19 8702786fa2 feat: 模型获取支持 CLI 端点回退,同族优先 chat 后降级 cli 2026-02-09 11:37:41 +08:00
fawney19 8673ed1459 refactor: 优化 kiro token 刷新逻辑和 build_all_format_configs
- kiro auth: 拆分无缓存 token 和占位符 key 的判断逻辑,避免不必要的解密操作
- kiro auth: 简化 effective_token 获取逻辑
- build_all_format_configs: 只对实际配置了端点的格式构建请求配置,
  不再用某个端点的 base_url 尝试其他未配置的格式
- get_adapter_for_format: 简化为单行表达式
- 修复 f-string 日志为 loguru 风格占位符
- 新增 build_all_format_configs 单元测试
2026-02-09 10:11:38 +08:00
fawney19 1b8e73bb5c Merge pull request #156 from AAEE86/master
fix(kiro): 修复请求时 403 bearer token invalid 错误
2026-02-09 09:39:42 +08:00
AAEE86 6ffdd38c3a fix(kiro): 修复测试模型时 403 bearer token invalid 错误
问题:
Kiro 测试模型时返回 403,原因是 get_provider_auth() 直接使用 key.api_key 作为 access_token,但新导入的 key 其 api_key 是占位符,且当 expires_at 未设置时不会触发刷新。

修复:
1. 如果没有缓存的 access_token 或 api_key 是占位符,强制刷新
2. Kiro 类型优先使用 auth_config 中缓存的 access_token
2026-02-09 03:44:13 +08:00
fawney19 a1c5aa4e04 Merge pull request #155 from AAEE86/master
feat: 删除 getKeyDisplayName 前端统一使用 key.name 显示
2026-02-09 03:06:26 +08:00
AAEE86 d2f2d7f92d feat: 删除 getKeyDisplayName 前端统一使用 key.name 显示 2026-02-09 03:02:36 +08:00
fawney19 3e1a046140 fix(kiro): 导入时将获取到的 email 写回 auth_config 以确保持久化 2026-02-09 03:01:13 +08:00
fawney19 35b57542d1 Merge pull request #154 from AAEE86/master
fix(kiro): 统一 Kiro 导入时的 name 字段为邮箱格式
2026-02-09 02:58:11 +08:00
fawney19 cfa768eebd feat: 请求体规则扩展(append/insert/regex_replace)、OAuth 代理节点支持与列表分页
- 请求体规则新增 append、insert、regex_replace 三种操作,路径语法支持数组索引
- OAuth 授权/导入/批量导入支持指定代理节点(proxy_node_id),Key 级代理避免 IP 污染
- 密钥列表、模型映射、模型列表添加智能分页(useSmartPagination)
- AdvancedGuide 新增请求体规则使用指南与示例
- 简化 Codex enrich_codex 实现,README 添加 QQ 群二维码
2026-02-09 02:56:21 +08:00
AAEE86 dc76721d2c fix(kiro): 统一 Kiro 导入时的 name 字段为邮箱格式
- 删除 _generate_kiro_key_name() 函数
- 新增 _fetch_kiro_email() 通过 getUsageLimits API 获取邮箱
- Kiro 导入时 name 字段与 Codex/Antigravity 保持一致,使用邮箱
- 获取邮箱失败时 fallback 到 "账号_{timestamp}" 格式
2026-02-09 02:40:21 +08:00
fawney19andAAEE86 2f8af7c96b fix(codex): 移除未使用的 user_id 字段,修复请求上下文未清理的问题
Co-Authored-By: AAEE86 <[email protected]>
2026-02-09 01:11:24 +08:00
AAEE86 3153c60291 fix: Codex 反代添加 chatgpt-account-id 请求头
通过 contextvars 将 OAuth 认证配置中的 account_id 从 wrap_request()传递到 extra_headers(),修复实际请求时缺少 chatgpt-account-id 头导致的错误。
2026-02-09 01:07:38 +08:00
fawney19andAAEE86 b34dd12863 feat: 新增 Kiro 适配器、OAuth 改进与多项功能增强
- 新增 Kiro provider 适配器(EventStream 协议解析、令牌管理、用量追踪)
- 重构 OAuth 账户管理与统一配额机制
- 重构 Handler 基类(CLI adapter/handler、请求构建器、流处理器)
- 增强缓存监控后端 API 与前端可视化
- 改进 Gemini 格式标准化器与请求头处理
- Antigravity/Codex 适配器更新,移除旧 metadata_collector
- 新增数据库迁移:proxy provider API keys
- 前端 UI 多项优化

Co-Authored-By: AAEE86 <[email protected]>
2026-02-09 01:05:48 +08:00
fawney19 e324ffdcd6 fix(proxy): 修复连通性测试调用 build_hmac_proxy_url 参数不匹配
- 移除 _build_test_proxy_url 中多余的 node.id 位置参数,与函数签名保持一致
2026-02-08 17:14:56 +08:00
fawney19 183e7c60e1 refactor(proxy): 简化 HMAC 认证、移除明文 HTTP 代理并优化 setup 流程
- HMAC 签名移除 node_id,仅使用 timestamp,消除重注册时的认证竞态问题
- 删除 plain HTTP forward proxy(plain.rs),仅保留 CONNECT 隧道和 delegate,非支持方法返回 405
- 提取共享 BoxBody 类型和 empty_box_body() 到 proxy/mod.rs
- CLI 解析重构为 clap 原生 subcommand,启用 subcommand_negates_reqs
- setup 向导返回 SetupOutcome 枚举,支持保存后自动启动 proxy
- setup TUI 增加未保存变更的退出确认(pending_quit)
- validate_target 改为 async,使用 tokio::net::lookup_host 避免阻塞 DNS
- 显式初始化 rustls ring CryptoProvider
- ConfigFile 新增 inject_env_override() 用于 setup 后重载配置
- Python 端 HMAC 签名同步移除 node_id,缓存时间桶从 120s 调整为 240s
2026-02-08 16:10:13 +08:00
fawney19 f07cae540e feat: aether-proxy 自升级功能
新增 `aether-proxy upgrade [version]` 命令,支持从 GitHub Releases
下载指定或最新版本的二进制,校验 SHA256 后原子替换当前二进制,
并在 systemd 服务运行时自动重启。
2026-02-08 14:54:10 +08:00
fawney19 abd85f777f fix: aether-proxy 作为 systemd 服务启动时跳过自身活跃检测,避免无限重启 2026-02-08 14:19:48 +08:00
fawney19 519ad67eb1 refactor: 代理节点架构重构与功能增强
aether-proxy:
- 重构 main.rs,拆分为 app/state/hardware/net 模块
- setup.rs 拆分为 setup/tui.rs + setup/service.rs,支持 systemd 服务管理子命令
- 新增 delegate 端点,支持后端通过代理节点转发请求而非传统 CONNECT 代理
- 注册时上报硬件信息(CPU/内存/fd_limit)和估算最大并发数
- 心跳上报活跃连接数,支持远程下发 node_name 配置
- HTTP 转发时剥离 X-Forwarded-* 等敏感头部
- 切换到 rustls-tls,降低日志级别减少噪音

后端:
- 从 http_client.py 提取代理相关逻辑至 proxy_node/resolver.py
- 从 routes.py 提取业务逻辑至 proxy_node/service.py
- handler 支持 delegate 模式(通过代理节点 HTTP 端点转发而非 CONNECT 隧道)
- ProxyNode 模型新增 hardware_info 和 estimated_max_concurrency 字段

前端:
- 新增 HardwareTooltip 组件展示节点硬件信息
- 远程配置支持下发 node_name
2026-02-08 13:33:08 +08:00
fawney19 254d30d32d feat: 请求候选记录中追踪代理节点信息并在前端 timeline 展示 2026-02-08 01:18:00 +08:00
fawney19 4a1029961a feat: 请求日志和 usage 记录中追踪代理节点信息
- 新增 resolve_proxy_info/get_proxy_label 提取脱敏的代理摘要
- StreamContext 增加 proxy_info 字段,流式/非流式全路径写入 metadata
- ProxyNode 缓存补充 name 字段,日志输出代理节点标识
- aether-proxy 连接和转发日志从 debug 提升为 info 级别
- TaskService 代理不可用日志补充 node_id 细节
2026-02-08 00:49:43 +08:00
fawney19 5384ffd403 feat: aether-proxy TLS 双栈支持与自签名证书自动生成
- aether-proxy 新增 TLS 模块:自签名证书生成、TLS acceptor 构建、证书 SHA-256 指纹计算
- 代理服务器支持 HTTP+TLS 双栈模式,通过 peek 首字节区分 TLS ClientHello 与普通 HTTP
- 注册与心跳上报 tls_enabled 和 tls_cert_fingerprint 字段
- Python 侧 httpx 代理适配:TLS 代理使用 httpx.Proxy + CERT_NONE ssl_context
- ProxyNode 模型新增 tls_enabled/tls_cert_fingerprint 字段及对应迁移
2026-02-07 23:18:58 +08:00
fawney19 10bd14c223 fix: generate_keys 补充 PROXY_HMAC_KEY 生成,CLI handler 增加 ReadError 容错
- generate_keys.py 新增 PROXY_HMAC_KEY 生成并输出 aether-proxy.toml 配置示例
- .env.example 完善 PROXY_HMAC_KEY 注释说明
- cli_handler_base.py 两处流式处理增加 httpx.ReadError 异常捕获,
  代理连接中断时与 RemoteProtocolError 保持一致的降级处理
2026-02-07 20:35:25 +08:00
fawney19 31bc452374 feat: aether-proxy 远程配置下发、连通性测试与 setup TUI
- 后端新增远程配置管理 API (PUT /config) 和连通性测试 API (POST /test)
- 前端新增远程配置编辑对话框和节点连通性测试按钮
- aether-proxy 支持通过心跳接收并热加载远程配置 (端口白名单、日志级别、心跳间隔、时间戳容差)
- aether-proxy 新增 TOML 配置文件支持和交互式 setup TUI
- aether-proxy 心跳 404 时自动重注册节点
- plain proxy 响应改为流式传输,减少内存缓冲
- 新增 remote_config 和 config_version 数据库字段及迁移
2026-02-07 19:20:09 +08:00
fawney19 3b8398b2e5 fix: deploy.sh 容器未运行时自动启动,避免无变更时跳过启动 2026-02-07 14:53:46 +08:00
fawney19 02906dad0e fix: 增强代理密码脱敏策略并补充异常日志
- 密码脱敏阈值从 4 提升至 8,短密码全部遮蔽
- 为系统代理获取、proxy_node 解析、代理 URL 构建添加 warning 日志
2026-02-07 14:37:44 +08:00
fawney19 db96c9a46e feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取
代理节点:
- 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签
- 新增手动节点的 CRUD API 和前端管理界面
- 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id)
- 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用
- 删除节点时自动清除系统默认代理引用并失效缓存
- 健康检查跳过手动节点(无心跳,始终在线)

流式处理:
- CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage
- StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage
- 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
2026-02-07 14:30:46 +08:00
fawney19 86dbbe83c2 fix(ci): vendored OpenSSL for cross-compilation
cross 的 Docker 镜像缺少 libssl-dev,改用 native-tls-vendored 静态链接 OpenSSL
2026-02-07 12:52:25 +08:00
fawney19 6d95822e19 fix(ci): macOS amd64 改用 macos-latest 交叉编译
macos-13 (Intel) runner 已被 GitHub 下线
2026-02-07 12:52:25 +08:00
fawney19 01653efa84 ci: 添加 aether-proxy 多平台自动编译与 README 文档
- 新增 GitHub Actions workflow,推送 proxy-v* tag 自动编译 5 个平台
  (linux-amd64, linux-arm64, macos-amd64, macos-arm64, windows-amd64)
- 编译产物自动发布到 GitHub Releases 并生成 SHA256 校验文件
- 新增 aether-proxy README,包含部署、配置、后台运行等完整说明
2026-02-07 12:52:25 +08:00
fawney19 1180634269 feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强
ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
2026-02-07 12:52:25 +08:00
fawney19 62f852b851 feat: Antigravity 端点签名迁移至 gemini:chat,流式 usage 提取重构与请求详情自动刷新
- Antigravity 端点签名从 gemini:cli 统一为 gemini:chat,保留向后兼容,含 DB 迁移
- 流式处理中 usage/completion/text 提取抽离为 _update_ctx_from_provider_event,
  支持 envelope 解包后提取,避免格式转换流程中重复计数
- 新增 has_format_conversion 属性,区分真正的格式转换与 envelope rewrite,
  修正 usage 展示层的格式转换标记
- 请求详情抽屉对未完成请求支持自动轮询刷新,关闭时自动停止
- 按次计费样式调整;实际成本仅在倍率非 1.0 时显示;缓存日志降级为 trace
2026-02-07 02:59:02 +08:00
fawney19 ea22f07046 test: 添加 OpenAI chat completions 测试脚本 2026-02-06 23:07:23 +08:00
fawney19 18d0af3dbf feat: 格式转换支持三层开关控制(全局/Provider/端点)
- _get_convertible_formats 始终返回所有可转换格式,由下游精确过滤
- get_compatible_provider_formats 增加 Provider 级别 enable_format_conversion 判断
- Provider 设置更新后自动失效相关缓存(模型列表、解析缓存、Provider 缓存)
- 前端 toggleFormatConversion 使用接口返回的完整对象更新本地状态
2026-02-06 23:01:56 +08:00
fawney19 a1ea060cd9 fix: 流式响应上游连接异常时优雅降级,避免丢失 usage 和 telemetry
上游 httpx.StreamClosed/HTTPError 发生时,若已向客户端输出数据则
best-effort flush 残留 SSE 并标记 502 结束流,保证 StreamingResponse
背景任务正常执行;若尚未输出则 re-raise 以触发 failover。
2026-02-06 21:57:05 +08:00
fawney19 8b6a5d3824 feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强
- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON
- OAuth: 所有 OAuth 端点添加 require_admin 鉴权
- 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids
- Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段)
- 流式处理: buffer 残留数据 flush 与 token 兜底估算
- 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并
- Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组
- README: 新增升级备份与回滚指南
2026-02-06 21:52:22 +08:00
fawney19 62dae22a2c fix: 流式响应健壮性增强与候选排序稳定性修复
- test-model 接口优先使用流式请求,失败自动回退非流式
- CLI 流式处理在 StreamClosed/RemoteProtocolError 时 flush 残余 SSE 数据,捕获尾部 usage
- 流未正常完成时兜底估算 tokens,避免 usage 记录为 0
- ProviderCandidate 添加 __lt__ 解决 tuple 排序 TypeError
- sorted() 添加显式 key 参数避免隐式比较候选对象
- 异常日志改用 logger.opt(exception=e) 替代手动 traceback
2026-02-06 18:06:38 +08:00
fawney19 b88fb6273b refactor: Antigravity/Codex 服务重构为插件化适配器架构
- 将 Antigravity 和 Codex 从独立模块迁移至 src/services/provider/adapters/ 插件体系
- 新增 provider_types 和 oauth_token 模块,移除 maintenance_scheduler 中的 OAuth 定时刷新
- 增强 admin API:扩展 keys 和 provider_query 端点,新增 dashboard 路由
- 大幅增强 ProviderDetailDrawer 组件,新增 AntigravityQuotaDialog
- 改进 handler 基类(chat/cli)和错误分类器
- 优化 fetch_scheduler 和 upstream_fetcher
- 前端 UI 组件清理和优化
- 更新测试以匹配新模块结构
2026-02-06 16:37:06 +08:00
fawney19 e01dfee41d feat: body rules 支持嵌套路径并增强前端验证体验
- 后端 apply_body_rules 支持点号分隔的嵌套路径(如 metadata.user.name)
- 支持 \. 转义字面量点号(如 config\.v1.enabled)
- 配置导出导入升级至 v2.2,新增 SystemConfig 支持
- 前端请求体规则编辑器改用 JSON 格式输入,增加实时验证指示
- 用量记录表格新增移动端卡片视图,优化筛选器响应式布局
2026-02-05 20:13:41 +08:00
fawney19 a9b24c9161 fix: Codex 刷新限额功能优化
- 修复并发协程中直接操作 SQLAlchemy Session 的问题,改为收集后统一更新
- 抽取硬编码模型名为 CODEX_QUOTA_REFRESH_MODEL 常量
- 前端刷新限额前添加确认对话框,提示会产生 API 调用费用
- 修正 loguru 日志格式为 {} 占位符风格
2026-02-05 18:09:40 +08:00
AAEE86 93090e227f feat: Codex 提供商批量刷新限额功能 (#147)
- 后端 (keys.py):新增 POST /providers/{provider_id}/refresh-quota API,向 Codex 提供商的所有活跃 Key 发送测试请求,从响应头解析限额信息并更新数据库
- 前端 (ProviderDetailDrawer.vue):在 Codex 类型提供商的密钥管理区域添加"刷新限额"按钮,带加载动画和结果提示
- 前端 API (keys.ts):新增 refreshProviderQuota 接口调用及 RefreshQuotaResult 类型定义
- 其他:description 字段允许 null,公开 ensure_collectors_registered 函数
2026-02-05 18:09:14 +08:00
fawney19 5b9ba06af6 feat: base image hash 改用依赖指纹,上游流式策略改为三态按钮
CI 和 deploy.sh 的 base image hash 计算从整文件 cat 改为
tomllib 提取 pyproject.toml 依赖指纹,避免仅改注释或工具配置
触发不必要的 base 重建;前端将上游流式策略从 Select 下拉改为
头部三态循环按钮(跟随请求/固定流式/固定非流),点击即保存。
2026-02-05 17:20:34 +08:00
fawney19 8ad53b9490 fix: 修复配置导入时 api_formats 与 endpoint 格式比较不一致的 bug
原代码使用 .upper() 转大写与小写的 endpoint_formats 比较,
导致所有格式都被误判为"未配置对应 Endpoint"。
改为统一使用 normalize_signature_key 归一化后比较。
2026-02-05 16:51:52 +08:00
fawney19 b35ddcba3f feat: 限制新建提供商类型为自定义和 Codex
新建模式下只显示自定义和 Codex 两个选项,编辑模式保留所有类型以兼容已有数据
2026-02-05 16:34:28 +08:00
fawney19 5be7813ab8 refactor: 将访问令牌功能迁移至模块系统
- 新增 management_tokens 模块定义,支持通过环境变量控制可用性
- 从静态路由中移除 management-tokens,改由模块动态注册
- 前端路由添加模块激活检查,未激活时重定向到仪表盘
- 认证服务添加模块激活检查,未激活时禁止令牌认证
- 导航菜单中移除访问令牌入口(保留直接 URL 访问)
- 修复 alembic 迁移脚本格式和错误处理
2026-02-05 16:32:27 +08:00
fawney19 440721368f feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理
- 新增 Codex 服务:信封处理、元数据收集器
- 重构 provider transport 支持新的服务架构
- 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应
- 优化 OAuth 工具函数
- 添加相关测试用例
2026-02-05 15:59:41 +08:00
fawney19 ed2ff5c1d7 feat: 性能监控基础设施、解密缓存及计费简化
- 新增 PerfRecorder 性能记录工具,支持采样率与慢请求日志
- 在请求管道中埋点:auth、body_read、json_parse、context_build、authorize、handle
- 流处理器增加 parse/conversion 耗时追踪与 perf_metrics 落库
- 解密服务添加 LRU 缓存,降低高频解密 CPU 开销
- 格式转换分层开关设计:全局 OFF 时回退到端点配置,而非一刀切拒绝
- 移除 shadow billing 模块,统一使用新计费引擎
- 新增 Codex 网关请求适配器(store=false、role 映射、include 补齐)
- endpoint 创建接口支持 body_rules 参数
2026-02-05 14:22:11 +08:00
fawney19 e72e5370c4 refactor: 提取 is_codex_url 到公共 utils 模块
将重复的 Codex URL 判断逻辑提取到 src/utils/url_utils.py,
避免 transport.py 和 adapter.py 中的代码重复。
2026-02-05 11:17:00 +08:00
fawney19 bb125a1ddc Merge pull request #146 from AAEE86/master
fix: Codex 端点 URL 使用 /responses 而非 /v1/responses
2026-02-05 11:15:08 +08:00
AAEE86 fecf48e180 fix: Codex 端点 URL 使用 /responses 而非 /v1/responses
Codex OAuth 端点(chatgpt.com/backend-api/codex)不走标准 /v1 前缀,之前 build_provider_url() 统一使用 /v1/responses 导致 404 错误。

修改内容:
- transport.py: build_provider_url() 对 Codex URL 做特判,使用 /responses
- EndpointFormDialog.vue: placeholder 对 Codex 端点显示正确路径
- guide-config.ts: 文档说明两种路径格式
2026-02-05 09:48:37 +08:00
fawney19 9c19a72d67 refactor: 移除 KeyFormDialog 中的 OAuth 认证逻辑
OAuth 流程已由独立的 OAuthAccountDialog 处理,移除 KeyFormDialog 中冗余的
OAuth 表单、授权流程 UI 和状态管理代码。同时清理 provider_oauth.ts 中不再
使用的接口和函数,修复 ProviderOAuthCompleteRequest 重复声明问题。
2026-02-05 02:56:33 +08:00
fawney19 d9d3a1afcf feat: 提供商详情头部布局优化及行内描述编辑
- 重构详情抽屉头部:网站链接改为图标按钮,布局更紧凑
- 新增行内描述编辑功能,支持点击编辑、回车保存、Esc 取消
- 表单对话框移除描述字段,名称字段改为独占一行
- 修复次级限额重置时间的缩进问题
2026-02-05 02:23:41 +08:00
fawney19 34272af711 feat: 跨格式转换时按 ApiFamily 优先级排序端点
- 为 ApiFamily 枚举添加 priority 属性 (OpenAI=1, Claude=2, Gemini=3)
- 新增 _sort_endpoints_by_family_priority 函数按优先级排序端点
- 在调度器中对各分组内的端点应用优先级排序
- 修正测试文件的 type ignore 注解 (attr-defined -> method-assign)
- 新增 7 个端点排序相关的单元测试
2026-02-05 02:01:06 +08:00
fawney19 9a8f25d1a9 feat: OAuth Token 自动刷新调度、OAuth 对话框优化及 OpenAI CLI normalizer 重构
- 系统设置新增 OAuth Token 自动刷新任务开关,支持动态调度
- OAuth 授权对话框简化步骤布局,移除编号圆圈样式
- 重构 OpenAI CLI normalizer:将 if-else 链替换为事件处理器映射表,
  将 stream_event_from_internal 拆分为独立方法
- 维护调度器在禁用刷新时移除已调度的 job
- .gitignore 新增 CLIProxyAPI/ 和 sub2api/ 排除项
- 补充 noop/unknown 事件处理器的测试覆盖
2026-02-05 01:07:47 +08:00
fawney19 ba4d88e8ce Merge pull request #141 from AAEE86/dev
feat: 支持固定类型 Provider OAuth 授权
2026-02-05 00:08:58 +08:00
fawney19 73249f09e4 Merge remote-tracking branch 'origin/master' into dev
# Conflicts:
#	src/api/handlers/base/request_builder.py
#	src/api/handlers/openai_cli/adapter.py
#	src/services/system/maintenance_scheduler.py
2026-02-05 00:07:29 +08:00
fawney19 4d6e7c094f feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
2026-02-04 23:59:45 +08:00
fawney19 24c9105628 feat: 扩展多个字符串列为 TEXT 类型并添加 tls-client 可选依赖
数据库迁移:
- 扩展 provider_api_keys.api_key 为 TEXT(OAuth tokens 可能很长)
- 扩展 ldap_configs 的 bind_dn、base_dn、user_search_filter 为 TEXT
- 扩展 oauth_providers.client_id 为 TEXT
- 添加 SQLite 兼容支持(batch 模式)
- 添加表/列存在性检查

依赖:
- 添加 tls-client 作为可选依赖 [tls]
2026-02-04 16:45:05 +08:00
fawney19 c996078f30 refactor(frontend): 优化提供商表单布局和固定类型端点管理
- 提供商表单调整字段顺序:提供商类型放到名称前面,描述和主站链接放在同一行
- 简化固定类型提示文本
- 固定类型 Provider 端点管理中隐藏删除按钮
- 移除多余的锁定提示文本
2026-02-04 16:44:26 +08:00
AAEE86 4c1ec66365 feat: OAuth 密钥编辑时显示到期时间和重新授权按钮
- 后端 EndpointAPIKeyResponse 新增 oauth_expires_at 字段
- 从加密的 auth_config 中解密并提取 OAuth Token 到期时间
- 前端编辑已授权的 OAuth 密钥时显示"重新授权"按钮
- 在按钮旁显示 Token 到期时间
2026-02-04 16:05:25 +08:00
Yorhaandfawney19 26a0f99f8f fix: 统一缓存token计费口径,避免OpenAI错误计费 (#144)
* fix: 统一缓存token计费口径,避免OpenAI错误计费

* fix: 添加 Gemini 缓存 token 归一化支持,优化代码结构

- Gemini 的 promptTokenCount 包含 cachedContentTokenCount,需要扣除
- 合并重复的 helper 函数为 _get_api_family()
- 将 import 移到文件顶部
- 补充 Gemini 测试用例

---------

Co-authored-by: fawney19 <[email protected]>
2026-02-04 16:04:09 +08:00
fawney19 f64631a3a3 feat: 导入上游模型时自动匹配或创建 GlobalModel
- 导入模型时按名称精确匹配已有的 GlobalModel
- 如果没有匹配到则自动创建新的 GlobalModel
- 导入完成后清除 /v1/models 缓存使模型参与路由
2026-02-04 15:28:57 +08:00
AAEE86 cb4407ba49 feat: 新增 Codex 上游兼容整流器
- 强制 store=false
- 确保 instructions 字段存在(缺失/None -> "")
- 强制 parallel_tool_calls=true,并确保 include 包含 reasoning.encrypted_content
- 删除 Codex 会拒绝的一些字段:max_output_tokens/max_completion_tokens/max_tokens/temperature/top_p/service_tier
- 将 input[] 里 role=system 的 message 改成 developer
- 在发送上游请求前注入整流(覆盖“同格式透传”和“跨格式转换”两条链路)
2026-02-04 15:09:51 +08:00
fawney19 bf4e2d7a88 Merge pull request #143 from hemo94931/master
fix: 修复一些测试模型相关的bug
2026-02-04 14:09:31 +08:00
fawney19 065b1caaf9 feat: support more request statuses in timeline 2026-02-04 13:32:42 +08:00
fawney19 2516460a0d fix: show cancelled requests in timeline 2026-02-04 13:26:33 +08:00
fawney19 d15b294c38 refactor: 重新设计定时任务 UI
- 使用模板化结构渲染定时任务列表,便于扩展
- 统一时间选择器样式:时钟图标 + 独立的时/分下拉框
- 优化卡片布局:开关、图标、标题、时间配置在同一行
- 未启用时图标无背景色,启用时显示主题色背景
- 额外配置项收纳在展开区域中
2026-02-04 13:14:11 +08:00
AAEE86 8c8a431428 feat: OAuth HTTP 请求添加重试机制和增强错误日志
- 新增 _redact_url 函数移除 URL 敏感参数
- 新增 _format_exc_chain 函数格式化异常链
- _httpx_post 对 ConnectError/TimeoutException 增加重试(最多 2 次)
- 增强日志输出:记录代理配置、主机、异常链等调试信息
2026-02-04 12:46:51 +08:00
hemo94931 44964916db fix: align openai cli default path 2026-02-04 12:18:39 +08:00
hemo94931 a80ddea93c fix: 修复测试规则保护头部解析 2026-02-04 12:18:39 +08:00
hemo94931 f1656c4960 fix: 测试模型中使用请求头和请求体规则修改请求 2026-02-04 12:18:39 +08:00
hemo94931 3b4ff95854 fix: OPENAI_CLI端点默认url修改 2026-02-04 12:18:39 +08:00
hemo94931 316d7055f5 fix: 修复测试模型时OPENAI_CLI请求包格式 2026-02-04 12:18:39 +08:00
fawney19 9ffe4b4ce8 Merge branch 'pr-140' 2026-02-04 12:17:46 +08:00
fawney19 3ffc6f21c4 refactor: 限制外部 API 仅接受 GlobalModel.name
移除调度器和模型映射器对映射名解析的支持,外部请求必须使用精确的
GlobalModel.name。同时新增 is_active 检查确保停用的模型被正确拒绝。

- 使用 get_global_model_by_name 替代 resolve_global_model_by_name_or_mapping
- 添加模型名称规范化(去除首尾空格)
- 统一检查 GlobalModel.is_active 状态
2026-02-04 11:49:47 +08:00
AAEE86 e4fdc65e52 feat: 支持固定类型 Provider OAuth 授权
- 新增 provider_type 字段区分自定义/预置 Provider 类型(claude_code/codex/gemini_cli/antigravity)
- 实现完整 OAuth 2.0 授权流程:start(生成授权 URL + PKCE)、complete(换取 token)、refresh
- 前端 KeyFormDialog 添加 OAuth 授权 UI,支持开始授权、粘贴回调 URL、完成授权、强制刷新
- 请求时自动检测 token 过期并刷新(120s 预留窗口 + Redis 分布式锁防并发)
- 固定类型 Provider 自动创建预置端点并锁定 base_url/custom_path
- 数据库迁移:添加 providers.provider_type,扩展 api_key 列为 TEXT
- 可选依赖 tls-client 用于 Claude token 请求的 TLS 指纹伪装
2026-02-04 10:24:25 +08:00
fawney19 f6dac1c38a feat: 请求入口提前创建 pending Usage 记录
在 chat/CLI handler 的流式和非流式请求入口处提前创建 pending 状态的
Usage 记录,让前端可以立即看到"处理中"的请求,提升用户体验。

- 新增 BaseMessageHandler._create_pending_usage() 方法
- ChatHandlerBase 流式/非流式入口调用
- CliMessageHandlerBase 流式/非流式入口调用
- 使用 try-except 包裹,创建失败不影响主流程
2026-02-04 03:27:58 +08:00
fawney19 7f09f191a1 feat: 优化用量状态同步机制和 OpenAI CLI 流式转换
- 增加状态回退保护,防止异步响应覆盖已知状态
- 添加轮询并发保护 (pollInFlight),避免重复请求
- 支持 cancelled 状态筛选和显示
- 前端 mergeRecordStatus 保护活跃记录状态
- 后端 streaming 状态同步更新改为使用当前 DB 会话
- OpenAI CLI 流式转换增加工具调用事件支持
- 轮询接口新增 target_model 字段返回
- 修复迁移脚本 inspector 缓存问题,改用 information_schema
2026-02-04 03:17:55 +08:00
fawney19 f3e2f84b38 feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
2026-02-04 02:04:54 +08:00
YorhaL 5d94dcc94d fix: 移除用户配额重置任务中的角色限制 2026-02-04 00:56:04 +08:00
YorhaL 381fc6b2cf style: 统一定时任务布局 2026-02-04 00:38:02 +08:00
YorhaL bac0e7fb20 feat: 添加用户配额自动重置功能及相关配置 2026-02-04 00:38:02 +08:00
fawney19 6f363a7703 perf: 优化流式响应内存占用和正则预编译
- 添加 record_parsed_chunks 标志,仅在 FULL 日志级别时累积 parsed_chunks
- 预编译 CJK 和敏感信息正则表达式,避免重复编译
- 优化 header 脱敏循环,预计算 lowercase 集合
- 移除 consumer_streams.py 中未使用的 message_fields 变量
- 将 SystemConfigService import 移至文件顶部
2026-02-03 21:32:53 +08:00
fawney19 d6597121f5 feat: Provider 签到任务时间可配置
- 前端添加签到时间选择器(HH:MM 格式)
- 后端支持动态更新调度器执行时间
- 新增 reschedule_cron_job 方法支持运行时重调度
2026-02-03 19:03:55 +08:00
fawney19andCC ddf3f5c107 feat: 添加请求体规则功能和 UI 优化
- 添加 body_rules 支持(set/drop/rename 操作)
- 后端 apply_body_rules 函数处理请求体修改
- 保护 model/stream 字段不被修改
- 前端 UI 重构:合并请求头/请求体规则为统一区域
- 使用左边框颜色区分规则类型(H=主题色,B=灰色)

Closes #139

Co-authored-by: CC <[email protected]>
2026-02-03 18:49:17 +08:00
fawney19 00442c41fa feat: 视频计费增强与影子计费系统 2026-02-03 18:48:39 +08:00
fawney19 ed68aebfb0 refactor: 统一任务框架 Phase 3 - 用 TaskService/FailoverEngine 替代 FallbackOrchestrator
核心重构:
- 移除 FallbackOrchestrator,用 TaskService + FailoverEngine 替代
- TaskService 作为统一入口,支持 SYNC/ASYNC 两种任务模式
- FailoverEngine 实现候选遍历、重试、故障转移逻辑
- 新增 AttemptFunc/AttemptResult 协议,统一尝试结果表示

功能改进:
- 流式响应首字节探测(30s 超时,空流触发故障转移)
- 流式取消归因优化(区分客户端断连 vs 服务端中断)
- 新增 OpenAI Sora 视频取消路由 POST /v1/videos/{task_id}/cancel
- OpenAI 流式请求自动添加 stream_options.include_usage

代码规范:
- 修复 loguru 日志格式(%s → {})
- 新增 FORMAT_CONVERSION_ENABLED 环境变量说明

测试覆盖:
- test_failover_engine.py: FailoverEngine 单元测试
- test_task_service_async_execute.py: TaskService ASYNC 模式测试
- test_video_cancel_e2e.py: 视频取消端到端测试
2026-02-02 21:16:28 +08:00
fawney19 ebe1a8d3e3 fix: 修复测试文件兼容性问题
- 重命名 golden 测试数据文件,将 `:` 替换为 `_` 以兼容 Windows 文件系统
- 修复 test_compatibility.py 中的参数名 global_conversion_enabled -> effective_conversion_enabled
2026-02-02 10:37:28 +08:00
fawney19 26493ff0ab refactor: 移除迁移脚本中自动创建 video endpoint 的逻辑
- 不再从 openai:chat/gemini:chat 自动复制创建 video endpoint
- 不再自动补齐 api_formats 中的 video 变体
- 不再复制 rate_multipliers/global_priority_by_format 到 video
2026-02-02 09:16:57 +08:00
fawney19 63bae5a31c fix: 修复迁移脚本中 Inspector 缓存导致的 DuplicateColumn 错误
在 table_exists、column_exists、index_exists、unique_constraint_exists
函数中添加 inspector.clear_cache() 调用,确保每次检查时从数据库获取
最新的 schema 信息,避免因缓存过期导致重复添加已存在的列/表/索引。
2026-02-02 03:20:10 +08:00
fawney19 9e31efe26c refactor: 重构异步任务系统和计费服务架构
- 重构任务系统:新增 lifecycle (TaskStatus/BillingStatus)、context、application 模块
- 将 video tasks 泛化为 async tasks,支持更通用的异步任务管理
- 新增 Gemini Files 管理模块和管理界面
- 重构 billing 服务:拆分 schema.py 和 service.py
- 新增 candidate 服务模块用于请求候选管理
- 数据库迁移:添加 billing_status、request_id、gemini_file_mappings 表和索引
- 移除废弃的 video_telemetry、task orchestrator 等模块
2026-02-02 03:16:52 +08:00
fawney19 feb7484fda fix: 迁移脚本创建 video endpoint 时补充 created_at/updated_at 字段
修复 create_video_endpoints() 函数 INSERT 语句缺少时间戳字段导致的
NOT NULL 约束错误。
2026-02-01 17:36:48 +08:00
fawney19 4ac8e63c94 feat: 添加视频生成功能增强和多维度计费系统适配
- 视频生成: 增强 video_handler,重构 task_poller,新增 telemetry
- 计费系统: 适配新的 signature 格式,支持 video 任务类型回退
- 数据库迁移: 添加 api_family/endpoint_kind 字段和 video_formats
2026-02-01 17:28:27 +08:00
fawney19 7b66505634 refactor: 全局适配 ApiFamily/EndpointKind 结构化标识体系
将新的 (ApiFamily, EndpointKind) / `family:kind` 签名体系应用到整个代码库:
- API Handlers: 所有 adapter/handler 使用新的签名格式
- Services: provider, model, usage, cache, auth 等服务层适配
- Database: ProviderEndpoint 新增 api_family/endpoint_kind 字段
- Frontend: Provider 管理、Usage 表格等组件适配
- Tests: 更新所有相关测试用例
2026-02-01 17:28:00 +08:00
fawney19 c246ccfc91 refactor(api_format): 重构为结构化的 (ApiFamily, EndpointKind) 标识体系
将扁平的 APIFormat 枚举 (CLAUDE, OPENAI, GEMINI, *_CLI) 重构为二维结构:
- ApiFamily: 协议族 (openai, claude, gemini)
- EndpointKind: 端点变体 (chat, cli, video, image)

新增 EndpointSignature 数据类和 `family:kind` 签名键工具函数,
统一 JSON dict/metrics/logs 中的字符串标识格式。
2026-02-01 17:25:28 +08:00
fawney19 2101a957ce fix: 优化 Redis 连接超时的错误日志输出
将 Redis 超时和连接错误改为 WARNING 级别的简短日志,
避免系统休眠恢复时打印大量堆栈信息。
2026-01-31 20:10:42 +08:00
fawney19 97b15afe7c feat: 添加多维度计费系统和视频任务管理功能
计费系统:
- 新增 BillingRule 和 DimensionCollector 数据模型
- 实现 FormulaEngine 安全表达式求值引擎 (AST 白名单)
- 支持 dimension/matrix/tiered/constant 多种维度映射
- BillingRuleService 支持 Provider Model -> GlobalModel 规则回退
- CLI task_type 在计费域自动映射为 chat

视频任务增强:
- 添加 request_metadata 字段记录候选 key 和计费规则快照
- 后台轮询支持并发控制 (Semaphore + 独立 session)
- 任务终态自动写入 Usage 记录并计算成本
- 新增视频任务管理 API 和前端界面

其他改进:
- UsageService 新增 record_usage_with_custom_cost 方法
- StandardizedUsage 支持 dimensions 字段 (兼容 extra)
- 配置新增 BILLING_REQUIRE_RULE 和 BILLING_STRICT_MODE
2026-01-31 19:11:25 +08:00
fawney19 dc4bb25cc2 feat: 添加 Vertex AI Claude 模型支持和动态格式识别
- 新增 get_vertex_ai_effective_format 函数,根据模型名自动判断 API 格式
- 支持通过 auth_config.model_format_mapping 自定义模型格式映射
- 修改 Vertex AI URL 构建逻辑,Claude 模型使用 anthropic publisher 和 rawPredict
- 修复 auth_config 可能是未加密 dict 的兼容性问题
2026-01-31 00:30:07 +08:00
fawney19 772cb90f64 feat: 添加视频生成 API 支持和认证抽象层重构
- 新增 Video Generation API 路由和处理器(支持 Gemini Veo 和 OpenAI Sora 兼容格式)
- 新增 AuthHandler 策略模式,统一 API key 提取逻辑(Bearer/ApiKey/GoogApiKey/OAuth2/QueryKey)
- 新增 RequestContext 三维度检测(数据格式/端点类型/认证方式)
- 新增 EndpointType 和 AuthMethod 枚举
- Gemini/OpenAI normalizer 添加视频格式转换(InternalVideoRequest/Task/PollResult)
- 新增视频任务轮询服务和数据库迁移(video_tasks 表)
- 代码格式化:修复 black 行宽限制,调整 import 排序,target-version 降级至 py313
2026-01-30 22:41:42 +08:00
fawney19 16fb06ff4c perf: 使用 BuildKit 缓存加速 Docker 构建
- 添加 # syntax=docker/dockerfile:1 启用 BuildKit 新语法
- apt-get 使用 --mount=type=cache 缓存包下载
- pip install 使用 --mount=type=cache 缓存依赖
- npm ci 使用 --mount=type=cache 缓存模块
- 添加 --no-install-recommends 减少安装包体积

缓存命中后构建时间从 ~40s 降至 ~1.5s
2026-01-30 15:03:58 +08:00
fawney19 5603c72f40 fix: 修复 mypy 类型检查错误并升级到 Python 3.14
主要变更:
- 修复 1483 个 mypy 类型检查错误
- 添加缺失的类型注解 (Any, Callable, Session 等)
- 修复隐式 Optional 类型 (param: Type = None -> param: Type | None = None)
- 修复 __new__ 单例模式返回类型
- 添加 type: ignore 注释处理第三方库类型问题
- 更新 pyproject.toml 依赖到 Python 3.14 兼容版本
- 更新 mypy/black 配置为 Python 3.14
2026-01-30 14:30:57 +08:00
fawney19 7066166757 style: 统一使用 PEP 604/585 现代类型语法
- Optional[X] → X | None
- Dict[X, Y] → dict[X, Y]
- List[X] → list[X]
- Tuple[X, Y] → tuple[X, Y]
- Set[X] → set[X]

涉及 10 个文件,共约 50 处改动。
2026-01-30 13:06:34 +08:00
fawney19 041a61f89c fix: 补充合并后遗漏的 from __future__ import annotations
合并 fix/python314-upgrade 后,部分文件仍使用 Optional/Dict 等旧式类型
但未添加 future annotations 导入,导致运行时 NameError。
2026-01-30 13:01:50 +08:00
fawney19 cd1b103223 Merge branch 'fix/python314-upgrade'
# Conflicts:
#	src/api/handlers/base/base_handler.py
#	src/api/handlers/base/request_builder.py
#	src/models/endpoint_models.py
#	src/services/orchestration/candidate_resolver.py
#	src/services/orchestration/fallback_orchestrator.py
2026-01-30 12:59:52 +08:00
fawney19 f8ca3773be fix: 恢复被误删的 database_extensions 导入
原 PR 误删了 database.py 末尾的导入语句,导致 ApiKeyProviderMapping
和 ProviderUsageTracking 模型不再被加载到 SQLAlchemy 注册表中。
2026-01-30 12:48:46 +08:00
fawney19 13487fe4e5 fix: 添加 from __future__ import annotations 修复运行时类型错误
原 PR 将 Optional[X] 改为 X | None 后,如果 X 在 TYPE_CHECKING 块中导入,
会导致运行时 NameError。添加 future annotations 使类型注解延迟求值。

影响文件:
- src/services/auth/service.py
- src/core/api_format/utils.py
- src/core/api_format/detection.py
- 及其他 15 个使用 TYPE_CHECKING 的文件
2026-01-30 12:43:08 +08:00
fawney19andAAEE86 a90f065ab0 fix: 对齐 requires-python 版本声明至 >=3.12
代码已使用 PEP 604 (T | None) 等 3.10+ 语法,
且 Docker 镜像已升级至 Python 3.14,
但 requires-python 仍声明 >=3.9 会导致在 3.9/3.10/3.11 上安装后运行时报错。

- requires-python: ">=3.9" → ">=3.12"
- 移除 classifiers 中 3.9/3.10/3.11 的声明

Co-authored-by: AAEE86 <[email protected]>
2026-01-30 12:38:29 +08:00
fawney19 c33cd07ea9 feat: add Gemini Files API proxy with key capability support
- Add gemini_files_api capability for key-level Files API support
- Implement Gemini Files API proxy endpoints (upload/list/get/delete)
- Add file-to-key mapping cache for correct key routing
- Support preferred_key_ids to prioritize keys that uploaded files
- Enhance logging for file mapping diagnostics
2026-01-30 11:37:34 +08:00
AAEE86 897ea2e7a4 perf: 优化 Provider 预加载和 bcrypt 密码验证性能
- 使用 selectinload 预加载 endpoints 避免 N+1 查询
- 将 bcrypt 密码验证移至线程池执行,避免阻塞事件循环
2026-01-30 10:55:30 +08:00
AAEE86 d0ecdcd8aa feat: 增加签到状态识别关键词"已经签到" 2026-01-30 08:42:36 +08:00
AAEE86 24d24f6829 chore: 升级到 Python 3.14 并现代化代码
- 升级 Docker 基础镜像从 Python 3.12 到 3.14
- 更新 pyproject.toml 支持 Python 3.13/3.14
- 移除 Python 3.8/3.9/3.10/3.11 分类器
- 更新 black 和 mypy 配置目标版本
- 将 get_event_loop() 替换为 get_running_loop() 加上 RuntimeError 处理
- 简化 compute_cost_sync 中的 asyncio.run 使用
- Dict/List/Tuple/Set → dict/list/tuple/set (PEP 585)
- Optional[T] → T | None (PEP 604)
- Union[A, B] → A | B (PEP 604)
- 移除废弃的 typing 导入
- 移除不必要的字符串引号注解
2026-01-30 03:10:21 +08:00
fawney19 32b293ef3e feat: add Vertex AI authentication support for provider API keys
- Add auth_type field to ProviderAPIKey model (api_key or vertex_ai)
- Implement Vertex AI OAuth token generation with service account
- Update transport layer to handle Vertex AI authentication
- Add Vertex AI endpoint URL generation in request builder
- Update frontend KeyFormDialog to support auth_type selection
- Add migration for auth_type column in provider_api_keys table
2026-01-30 02:43:50 +08:00
fawney19 3e75bc8964 fix(docker): sync Dockerfile.app.local with Dockerfile.app
- Add /auth to SPA routes
- Add /docs|redoc|openapi.json location block
- Add GUNICORN_WORKERS=4 default env var
2026-01-29 23:26:13 +08:00
fawney19 0b6763745c Merge pull request #128 from AAEE86/master
fix(frontend): 修复端点配置对话框中多个 Select 下拉框同时打开的冲突问题
2026-01-29 23:25:05 +08:00
fawney19 4487c35486 Merge pull request #127 from NyaDoo/master
fix: CLI 模型测试请求添加 x-app: cli 头部
2026-01-29 23:05:25 +08:00
fawney19 61abb47d22 refactor: 重构上游模型获取与展示逻辑
- 后端支持遍历所有活跃 API Key 聚合模型,按 model id 合并 api_formats
- 前端移除按 API 格式分组,改为统一展示并内联显示格式标签
- 改进 401 错误处理,账户异常时清除认证并重定向首页
2026-01-29 22:58:37 +08:00
AAEE86 b0b1bdf158 feat: 添加Gunicorn配置优化并改进Docker构建配置
- 新增 gunicorn_conf.py 配置文件,实现:
  - GC冻结优化以改善Copy-on-Write内存共享
  - Worker RSS内存监控日志
  - 添加 --max-requests 和 --max-requests-jitter 防止内存泄漏

- 改进 Dockerfile 配置:
  - 修复 Nginx 静态文件目录权限问题

- 简化 docker-compose 配置:
  - 移除冗余的 PYTHONIOENCODING/LANG/LC_ALL 环境变量
2026-01-29 22:53:42 +08:00
AAEE86 c26598c773 feat(frontend): 调整统一模型表单对话框尺寸
- 将对话框尺寸从 3xl 增大到 4xl
- 将创建模式的内容高度从 500px 增加到 600px
- 为模型选择和配置区域提供更多展示空间
2026-01-29 17:41:12 +08:00
AAEE86 2140c1443a fix(frontend): 修复端点配置对话框中多个 Select 下拉框同时打开的冲突问题 2026-01-29 17:16:30 +08:00
LewisPen ed2f4c1c34 fix: CLI 模型测试请求添加 x-app: cli 头部
CLI adapter 的 check_endpoint 测试请求缺少 x-app: cli 头部,
导致上游 Aether 节点选择了错误的 adapter 提取 API Key,
返回 401 认证失败。
2026-01-29 14:35:10 +08:00
fawney19 297da59448 refactor(guide): 重构教程页面为管理员视角
- 重新规划教程结构:概览、供应商管理、模型管理、用户与密钥、高级功能、常见问题
- 新增详细的功能解释和配置说明
- 删除旧的 CLI/SDK 用户指南页面
- 修复路由配置,添加 meta.requiresAuth: false
2026-01-29 13:58:06 +08:00
fawney19 ec9aea1f0e refactor(frontend): 优化首页 Header 响应式布局
分离移动端和桌面端的 Header 布局结构,提升不同屏幕尺寸下的显示效果
2026-01-29 12:11:53 +08:00
fawney19 013c807f0d feat(frontend): 新增配置教程页面并优化暗色模式样式
- 添加 Guide 配置教程页面及路由
- 统一暗色模式配色方案,使用协调的暖色调
- 重新设计功能卡片,区分已完成与开发中状态
- 更新功能卡片描述,反映当前开发重点
2026-01-29 11:41:55 +08:00
fawney19 407fcfff3e refactor(frontend): 代码格式化与移动端响应式优化
- 修正 Vue 组件 defineProps/defineEmits 声明顺序
- 优化首页移动端布局,调整 Logo 位置与透明度
- 为 Aether 标题添加打字机动画效果
- 修复 HTML 属性中引号的实体转义
2026-01-29 11:06:30 +08:00
fawney19 557e64c77f fix: 响应中保留用户请求的原始模型名
- 非流式响应:通过 requested_model 参数传递用户请求的模型名
- 流式响应:StreamState 初始化时使用 ctx.model 而非 ctx.mapped_model
- 流式转换:normalizers 保留初始 model 值,不被上游事件覆盖
- parsed_chunks:格式转换时记录转换后数据,确保与客户端收到的一致
2026-01-29 10:32:36 +08:00
fawney19 2e9c9e80d1 fix: 修复响应解析器中 usage 为 null 时的处理问题
将 response.get("usage", {}) 改为 response.get("usage") or {},
避免 usage 显式为 None 时导致后续属性访问失败。
2026-01-29 09:44:12 +08:00
fawney19 ac73f6fbac chore: 提升请求/响应体记录大小限制至 5MB 2026-01-29 09:33:11 +08:00
fawney19 06a46383a1 perf: 优化同步方法性能,避免事件循环开销
- pipeline.py: 日志格式改为 %-style 懒惰计算
- cost.py: get_model_price/get_tiered_pricing 改用直接数据库查询
- usage/service.py: 批量记录改为并行准备,提取 build_params 减少重复代码
2026-01-29 02:00:00 +08:00
fawney19 b4bbf4eeb1 feat: 添加 NekoCode 中转站架构支持
- 使用 Cookie 认证(session)
- 支持余额查询和每日配额显示
- 显示订阅状态和有效期
2026-01-28 23:51:06 +08:00
fawney19 6b34a7e3f5 feat: 多项功能优化与问题修复
- 添加高频轮询端点日志抑制,减少 debug 日志噪音
- Gemini 格式转换支持 responseModalities、thinkingConfig 透传和图片生成输出
- OpenAI 格式转换支持流式图片内容块,区分 URL 引用和 base64 内嵌图片
- Provider 余额缓存认证失败时使用短 TTL,避免前端无限加载中
- Usage 服务支持更新 pending/streaming 记录,处理重复 request_id 冲突
- Usage 超时检测增强,避免错误标记已完成请求为超时
2026-01-28 23:07:58 +08:00
fawney19 91986baa57 feat: 添加流式请求客户端断连检测
在等待上游首字节期间检测客户端是否已断开连接,若检测到断连则及时取消请求并清理资源,避免资源浪费。

- 新增 ClientDisconnectedException 和 wait_for_with_disconnect_detection 工具函数
- ChatHandlerBase 和 CliMessageHandlerBase 均支持 http_request 参数用于断连检测
- 流式传输期间使用后台任务主动检测断连状态
- 断连时设置状态码 499 并记录日志
2026-01-28 17:10:21 +08:00
fawney19 bae278a0c1 feat: 添加提供商格式转换优先级保持配置
- 新增 Provider.keep_priority_on_conversion 字段,控制格式转换时是否保持优先级
- 新增全局配置 KEEP_PRIORITY_ON_CONVERSION,可全局启用优先级保持
- 调度器根据配置决定是否降级需要格式转换的候选
- 前端 Provider 表单添加"保持优先级"开关
- 调整 HTTP 读写超时默认值为 3600 秒
2026-01-28 15:53:33 +08:00
fawney19 5e81a0b581 refactor: 移除 ModelsTab 单个删除按钮,简化表格样式
Close #119
2026-01-28 13:17:50 +08:00
3446 changed files with 1340619 additions and 131217 deletions
+10 -15
View File
@@ -1,23 +1,18 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
.uv/
*.egg-info/
dist/
# Build artifacts
build/
target/
*.so
*.egg
*.egg-info/
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
# frontend/dist/ - 注释掉,因为我们需要预构建的dist文件
aether-vscodex/web/node_modules/
aether-vscodex/web/dist/
aether-vscodex/vscode-extension/node_modules/
aether-vscodex/vscode-extension/dist/
# Development
.git/
@@ -59,4 +54,4 @@ Dockerfile.*
# Deployment
deploy/
scripts/
scripts/
+69 -17
View File
@@ -1,19 +1,48 @@
# ==================== 必须配置(启动前) ====================
# 以下配置项必须在项目启动前设置
# 应用端口(默认 8084)
APP_PORT=8084
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
# 示例:
# APP_IMAGE=ghcr.io/fawney19/aether:latest
# APP_IMAGE=ghcr.io/fawney19/aether:rc
# APP_IMAGE=ghcr.io/fawney19/aether:beta
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
# API Key 前缀(默认 sk)
API_KEY_PREFIX=sk
# Rust 日志过滤(默认 aether_gateway=info)
# 示例: aether_gateway=debug,sqlx=warn
RUST_LOG=aether_gateway=info
# CORS 配置(跨域带 Cookie 时不要写 *,必须显式列出前端源)
# 示例: http://localhost:5173,https://app.example.com
# CORS_ORIGINS=http://localhost:5173
# CORS_ALLOW_CREDENTIALS=true
# 如果前后端跨站并依赖登录刷新 Cookie,还要配合:
# AUTH_REFRESH_COOKIE_SAMESITE=None
# AUTH_REFRESH_COOKIE_SECURE=true
# 数据库配置
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_NAME=aether
DB_PASSWORD=your_secure_password_here
DB_PASSWORD=aether
# Redis 配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password_here
REDIS_PASSWORD=aether
# JWT密钥(使用 python generate_keys.py 生成)
# JWT密钥(使用 ./generate_keys.sh 生成)
# 用于用户登录 token 签名,更换后所有用户需重新登录
JWT_SECRET_KEY=change-this-to-a-secure-random-string
@@ -21,24 +50,47 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
ADMIN_EMAIL=[email protected]
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123456
ADMIN_USERNAME=admin123456
# ADMIN_PASSWORD=
# ==================== 可选配置(有默认值) ====================
# 以下配置项有合理的默认值,可按需调整
# 应用端口(默认 8084)
# APP_PORT=8084
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
# API Key 前缀(默认 sk)
# API_KEY_PREFIX=sk
# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要)
# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32
# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret
# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
# CORS 配置(允许跨域的源,多个源用逗号分隔)
# 示例: http://localhost:3000,https://example.com
# 默认: * (允许所有源)
# CORS_ORIGINS=*
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=120000
# 可选的 Payload 上限(MiB);默认及 0 均表示不限制。
# AETHER_MAX_REQUEST_BODY_MB=0
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=0
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=0
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
# work_mem=16MB, maintenance_work_mem=256MB。
# POSTGRES_SHARED_BUFFERS=8GB
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
# POSTGRES_SHM_SIZE=2gb
# POSTGRES_WORK_MEM=16MB
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
+239
View File
@@ -0,0 +1,239 @@
name: Build aether-tunnel
on:
push:
tags: ['tunnel-v*']
workflow_dispatch:
permissions:
contents: write
concurrency:
group: build-tunnel-${{ github.ref }}
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v5
- name: Ensure tunnel tag matches Cargo version
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
EXPECTED="${TAG#tunnel-v}"
ACTUAL="$(cargo metadata --manifest-path apps/aether-tunnel/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-tunnel") | .version')"
echo "tag version: ${EXPECTED}"
echo "cargo version: ${ACTUAL}"
if [ -z "${ACTUAL}" ]; then
echo "Could not resolve aether-tunnel package version" >&2
exit 1
fi
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
echo "tunnel tag ${TAG} does not match apps/aether-tunnel/Cargo.toml version ${ACTUAL}" >&2
exit 1
fi
build:
needs: preflight
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
- name: linux-musl-amd64
target: x86_64-unknown-linux-musl
os: ubuntu-latest
use_cross: true
- name: linux-musl-arm64
target: aarch64-unknown-linux-musl
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
os: macos-15-intel
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
os: macos-15
use_cross: false
- name: windows-amd64
target: x86_64-pc-windows-msvc
os: windows-latest
use_cross: false
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Ensure Rust target is installed
run: rustup target add ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/aether-tunnel -> target
key: ${{ matrix.target }}
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build
working-directory: apps/aether-tunnel
shell: bash
run: |
if [ "${{ matrix.use_cross }}" = "true" ]; then
cross build --release --locked --target ${{ matrix.target }}
else
cargo build --release --locked --target ${{ matrix.target }}
fi
- name: Package (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd target/${{ matrix.target }}/release
chmod +x aether-tunnel
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel
- name: Package (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
cd target/${{ matrix.target }}/release
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: aether-tunnel-${{ matrix.name }}
path: |
aether-tunnel-*.tar.gz
aether-tunnel-*.zip
if-no-files-found: error
retention-days: 1
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
merge-multiple: true
path: artifacts
- name: Generate checksums
working-directory: artifacts
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
- name: Delete stale draft releases for tag
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
if [[ -z "${draft_ids}" ]]; then
echo "No stale draft releases for ${RELEASE_TAG}"
exit 0
fi
while IFS= read -r release_id; do
[[ -z "${release_id}" ]] && continue
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
done <<< "${draft_ids}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "${{ github.ref_name }}"
generate_release_notes: true
files: |
artifacts/aether-tunnel-*
artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true
update-readme:
needs: release
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v5
with:
ref: main
- name: Update README download links
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#tunnel-v}"
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
if [ -d apps/aether-tunnel ]; then
TUNNEL_DIR="apps/aether-tunnel"
else
TUNNEL_DIR="aether-tunnel"
fi
cd "$TUNNEL_DIR"
TABLE="| Platform | Download |\n|----------|----------|\n"
TABLE+="| Linux x86_64 (GNU) | [aether-tunnel-linux-amd64.tar.gz](${BASE}/aether-tunnel-linux-amd64.tar.gz) |\n"
TABLE+="| Linux ARM64 (GNU) | [aether-tunnel-linux-arm64.tar.gz](${BASE}/aether-tunnel-linux-arm64.tar.gz) |\n"
TABLE+="| Linux x86_64 (musl) | [aether-tunnel-linux-musl-amd64.tar.gz](${BASE}/aether-tunnel-linux-musl-amd64.tar.gz) |\n"
TABLE+="| Linux ARM64 (musl) | [aether-tunnel-linux-musl-arm64.tar.gz](${BASE}/aether-tunnel-linux-musl-arm64.tar.gz) |\n"
TABLE+="| macOS x86_64 | [aether-tunnel-macos-amd64.tar.gz](${BASE}/aether-tunnel-macos-amd64.tar.gz) |\n"
TABLE+="| macOS ARM64 | [aether-tunnel-macos-arm64.tar.gz](${BASE}/aether-tunnel-macos-arm64.tar.gz) |\n"
TABLE+="| Windows x86_64 | [aether-tunnel-windows-amd64.zip](${BASE}/aether-tunnel-windows-amd64.zip) |"
# Replace content between markers
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
awk -v table="$TABLE" '
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
!skip { print }
' README.md > README.tmp && mv README.tmp README.md
fi
- name: Commit and push
run: |
if [ -d apps/aether-tunnel ]; then
TUNNEL_DIR="apps/aether-tunnel"
else
TUNNEL_DIR="aether-tunnel"
fi
cd "$TUNNEL_DIR"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git diff --cached --quiet && exit 0
TAG="${GITHUB_REF#refs/tags/}"
git commit -m "chore(tunnel): update download links for ${TAG}"
git push
+41 -5
View File
@@ -15,17 +15,53 @@ concurrency:
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-latest
outputs:
deploy_pages: ${{ steps.classify.outputs.deploy_pages }}
steps:
- name: Ensure stable Pages release tag
id: classify
shell: bash
run: |
set -euo pipefail
echo "deploy_pages=false" >> "${GITHUB_OUTPUT}"
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Manual Pages deployment."
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
tag="${GITHUB_REF_NAME}"
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Skipping Pages deploy for non-stable release tag: ${tag}"
exit 0
fi
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
build:
needs: preflight
if: needs.preflight.outputs.deploy_pages == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
cache-dependency-path: |
frontend/package-lock.json
aether-vscodex/web/package-lock.json
- name: Build aether-vscodex web
working-directory: aether-vscodex/web
run: |
npm ci
npm run build
- name: Install dependencies
working-directory: frontend
@@ -41,7 +77,7 @@ jobs:
run: cp frontend/dist/index.html frontend/dist/404.html
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
-186
View File
@@ -1,186 +0,0 @@
name: Build and Publish Docker Image
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
build_base:
description: 'Rebuild base image'
required: false
default: false
type: boolean
env:
REGISTRY: ghcr.io
BASE_IMAGE_NAME: fawney19/aether-base
APP_IMAGE_NAME: fawney19/aether
# Files that affect base image - used for hash calculation
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
jobs:
check-base-changes:
runs-on: ubuntu-latest
outputs:
base_changed: ${{ steps.check.outputs.base_changed }}
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check if base image needs rebuild
id: check
run: |
if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
echo "base_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Calculate current hash of base-related files
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
echo "Current base files hash: $CURRENT_HASH"
# Try to get hash label from remote image config
# Pull the image config and extract labels
REMOTE_HASH=""
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
fi
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
# No remote image or no hash label, need to rebuild
echo "No remote base image or hash label found, need rebuild"
echo "base_changed=true" >> $GITHUB_OUTPUT
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
echo "base_changed=true" >> $GITHUB_OUTPUT
else
echo "Hash matches, no rebuild needed"
echo "base_changed=false" >> $GITHUB_OUTPUT
fi
build-base:
needs: check-base-changes
if: needs.check-base-changes.outputs.base_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Calculate base files hash
id: hash
run: |
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
echo "hash=$HASH" >> $GITHUB_OUTPUT
- name: Extract metadata for base image
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
tags: |
type=raw,value=latest
type=sha,prefix=
labels: |
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
- name: Build and push base image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.base
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-app:
needs: [check-base-changes, build-base]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for app image
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Extract version from tag
id: version
run: |
# 从 tag 提取版本号,如 v0.2.5 -> 0.2.5
VERSION="${GITHUB_REF#refs/tags/v}"
if [ "$VERSION" = "$GITHUB_REF" ]; then
# 不是 tag 触发,使用 git describe
VERSION=$(git describe --tags --always | sed 's/^v//')
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Update Dockerfile.app to use registry base image
run: |
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
- name: Generate version file
run: |
# 生成 _version.py 文件
cat > src/_version.py << EOF
# Auto-generated by CI
__version__ = '${{ steps.version.outputs.version }}'
__version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit())
version = __version__
version_tuple = __version_tuple__
EOF
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
+613
View File
@@ -0,0 +1,613 @@
name: Nightly Release
on:
# 02:17 Asia/Shanghai (18:17 UTC) every day.
schedule:
- cron: '17 18 * * *'
workflow_dispatch:
# Checks and builds only need read access. Publishing jobs opt into write access
# below so a failed build cannot modify the existing nightly release.
permissions:
actions: read
contents: read
# A rolling tag and image are shared by scheduled and manually retried runs.
# Keep GitHub Release immutability disabled for this repository: the tag and
# assets intentionally move after each successful daily build.
concurrency:
group: nightly-main
cancel-in-progress: false
env:
CARGO_INCREMENTAL: '0'
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_PROFILE_TEST_DEBUG: '0'
CARGO_TERM_COLOR: always
RUST_BACKTRACE: '1'
GHCR_IMAGE: ghcr.io/fawney19/aether
jobs:
source:
name: Resolve main snapshot
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.snapshot.outputs.sha }}
short_sha: ${{ steps.snapshot.outputs.short_sha }}
date: ${{ steps.snapshot.outputs.date }}
steps:
- name: Require main branch
id: snapshot
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2
exit 1
fi
sha="${GITHUB_SHA}"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}"
echo "Building main at ${sha}."
# Keep the scheduled backend coverage in one place so it cannot drift from PR CI.
rust_ci:
name: Rust CI
needs: source
uses: ./.github/workflows/rust-ci.yml
rust_extended:
name: Rust extended checks
needs: source
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Show Rust toolchain
run: rustc -Vv
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-rust-1.95-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Check all workspace targets
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: cargo check --workspace --all-targets --all-features --locked
- name: Run workspace doctests
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: cargo test --workspace --all-features --doc --locked
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
run: sccache --show-stats
frontend:
name: Frontend checks and build
needs: source
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
cache: npm
cache-dependency-path: |
frontend/package-lock.json
aether-vscodex/web/package-lock.json
# The frontend prebuild synchronizes the embedded VSCodex UI by running
# its build from a separate package. Install that package explicitly so
# vue-tsc can resolve vite/client, vitest/globals, and node types in a
# clean runner.
- name: Install VSCodex web dependencies
working-directory: aether-vscodex/web
run: npm ci
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint
working-directory: frontend
run: npx --no-install eslint .
- name: Type-check
working-directory: frontend
run: npm run type-check
- name: Run unit tests
working-directory: frontend
run: npm run test:run
- name: Build nightly frontend
working-directory: frontend
env:
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
AETHER_VERSION: nightly
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v5
with:
name: nightly-frontend-dist
path: frontend/dist/
if-no-files-found: error
overwrite: true
retention-days: 7
repository_health:
name: Repository health checks
needs: source
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Check generated format coverage matrix
run: python3 docs/api/generate_format_field_coverage.py --check
- name: Test pressure report checker
run: node --test tools/pressure/check_gateway_stage_report.test.js
checks:
name: Nightly check gate
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- source
- rust_ci
- rust_extended
- frontend
- repository_health
steps:
- name: Verify check jobs
shell: bash
run: |
set -euo pipefail
failed=0
echo "source=${{ needs.source.result }}"
echo "rust_ci=${{ needs.rust_ci.result }}"
echo "rust_extended=${{ needs.rust_extended.result }}"
echo "frontend=${{ needs.frontend.result }}"
echo "repository_health=${{ needs.repository_health.result }}"
for result in \
"${{ needs.source.result }}" \
"${{ needs.rust_ci.result }}" \
"${{ needs.rust_extended.result }}" \
"${{ needs.frontend.result }}" \
"${{ needs.repository_health.result }}"; do
if [[ "${result}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo 'One or more nightly checks failed or were cancelled.' >&2
exit 1
fi
build:
name: Build ${{ matrix.name }}
needs: [source, checks]
if: ${{ needs.checks.result == 'success' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-musl
platform: linux
arch: amd64
os: ubuntu-latest
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-musl
platform: linux
arch: arm64
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
platform: macos
arch: amd64
os: macos-15-intel
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
platform: macos
arch: arm64
os: macos-15
use_cross: false
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-release-${{ matrix.target }}
workspaces: . -> target
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build release binary
env:
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
AETHER_VERSION: nightly
AETHER_BUILD_TYPE: release
CARGO_TERM_COLOR: always
shell: bash
run: |
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
cross build --release --locked -p aether-gateway --target "${{ matrix.target }}"
else
cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}"
fi
- name: Upload binary artifact
uses: actions/upload-artifact@v5
with:
name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }}
path: target/${{ matrix.target }}/release/aether-gateway
if-no-files-found: error
overwrite: true
retention-days: 7
docker:
name: Publish nightly GHCR image
needs: [source, checks, build]
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download Linux binaries and frontend
uses: actions/download-artifact@v5
with:
pattern: nightly-*
path: artifacts
merge-multiple: false
- name: Prepare Docker build context
shell: bash
run: |
set -euo pipefail
mkdir -p dist/frontend
cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64
cp -R artifacts/nightly-frontend-dist/. dist/frontend/
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push nightly image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.GHCR_IMAGE }}:nightly
${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }}
labels: |
org.opencontainers.image.title=Aether
org.opencontainers.image.version=nightly
org.opencontainers.image.revision=${{ needs.source.outputs.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
package:
name: Package nightly archives
needs: [source, checks, build]
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download nightly artifacts
uses: actions/download-artifact@v5
with:
pattern: nightly-*
path: artifacts
merge-multiple: false
- name: Build nightly release packages
shell: bash
env:
SOURCE_REF: ${{ needs.source.outputs.sha }}
run: |
set -euo pipefail
VERSION="nightly"
mkdir -p package release-assets
for platform in linux macos; do
for arch in amd64 arm64; do
bundle="aether-${VERSION}-${platform}-${arch}"
root="package/${bundle}"
mkdir -p "${root}/bin" "${root}/frontend"
install -m 0755 \
"artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \
"${root}/bin/aether-gateway"
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > "${root}/install.sh"
chmod 0755 "${root}/install.sh"
install -m 0755 update.sh "${root}/update.sh"
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
install -m 0644 .env.example "${root}/.env.example"
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
install -m 0644 README.md "${root}/README.md"
install -m 0644 LICENSE "${root}/LICENSE"
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
done
done
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > release-assets/install.sh
chmod 0755 release-assets/install.sh
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
(cd release-assets && sha256sum -c SHA256SUMS)
for archive in release-assets/*.tar.gz; do
tar -tzf "${archive}" >/dev/null
done
- name: Upload nightly package artifact
uses: actions/upload-artifact@v5
with:
name: nightly-release-assets
path: release-assets/*
if-no-files-found: error
overwrite: true
retention-days: 7
github_release:
name: Publish nightly GitHub Release
needs: [source, checks, docker, package]
if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: Download nightly package artifact
uses: actions/download-artifact@v5
with:
name: nightly-release-assets
path: release-assets
- name: Update rolling nightly release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
RELEASE_TAG: nightly
SOURCE_SHA: ${{ needs.source.outputs.sha }}
SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }}
RELEASE_DATE: ${{ needs.source.outputs.date }}
run: |
set -euo pipefail
release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})"
notes_file="${RUNNER_TEMP}/nightly-release-notes.md"
cat > "${notes_file}" <<EOF
## Aether nightly
This rolling prerelease was built from [main commit ${SOURCE_SHORT_SHA}](https://github.com/${REPOSITORY}/commit/${SOURCE_SHA}).
- Source branch: main
- Source commit: ${SOURCE_SHA}
- Build date (UTC): ${RELEASE_DATE}
- Container image: ${GHCR_IMAGE}:nightly
- Commit image: ${GHCR_IMAGE}:nightly-${SOURCE_SHA}
The nightly tag and assets are replaced by the next successful daily build.
EOF
# Create a draft on the first run. Later runs repair the same rolling
# release on retry if any upload or metadata update is interrupted.
if ! gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
gh release create "${RELEASE_TAG}" \
--repo "${REPOSITORY}" \
--draft \
--prerelease \
--latest=false \
--target "${SOURCE_SHA}" \
--title "${release_title}" \
--notes-file "${notes_file}"
fi
# Upload archives first, then the checksum/installer metadata. This
# keeps a failed upload from leaving a checksum that describes files
# which have not reached the Release yet.
gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \
--repo "${REPOSITORY}" \
--clobber
gh release upload "${RELEASE_TAG}" \
release-assets/SHA256SUMS \
release-assets/install.sh \
--repo "${REPOSITORY}" \
--clobber
# target_commitish does not move an existing git tag. Move the ref
# only after the complete asset set is available.
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
-f "sha=${SOURCE_SHA}" \
-F 'force=true' >/dev/null
else
gh api -X POST "repos/${REPOSITORY}/git/refs" \
-f "ref=refs/tags/${RELEASE_TAG}" \
-f "sha=${SOURCE_SHA}" >/dev/null
fi
gh release edit "${RELEASE_TAG}" \
--repo "${REPOSITORY}" \
--draft=false \
--prerelease \
--latest=false \
--target "${SOURCE_SHA}" \
--title "${release_title}" \
--notes-file "${notes_file}"
expected_assets=(
aether-nightly-linux-amd64.tar.gz
aether-nightly-linux-arm64.tar.gz
aether-nightly-macos-amd64.tar.gz
aether-nightly-macos-arm64.tar.gz
SHA256SUMS
install.sh
)
asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
for expected_asset in "${expected_assets[@]}"; do
if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then
echo "Published release is missing asset ${expected_asset}." >&2
exit 1
fi
done
resolved_sha=""
for attempt in {1..10}; do
resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)"
if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then
break
fi
sleep 2
done
if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then
echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2
exit 1
fi
release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
if [[ "${release_state}" != $'false\ttrue' ]]; then
echo "nightly release has unexpected state: ${release_state}" >&2
exit 1
fi
echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}."
summary:
name: Nightly summary
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- source
- rust_ci
- rust_extended
- frontend
- repository_health
- checks
- build
- docker
- package
- github_release
steps:
- name: Verify nightly pipeline
shell: bash
run: |
set -euo pipefail
failed=0
for entry in \
"source=${{ needs.source.result }}" \
"rust_ci=${{ needs.rust_ci.result }}" \
"rust_extended=${{ needs.rust_extended.result }}" \
"frontend=${{ needs.frontend.result }}" \
"repository_health=${{ needs.repository_health.result }}" \
"checks=${{ needs.checks.result }}" \
"build=${{ needs.build.result }}" \
"docker=${{ needs.docker.result }}" \
"package=${{ needs.package.result }}" \
"github_release=${{ needs.github_release.result }}"; do
echo "${entry}"
if [[ "${entry#*=}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo 'Nightly pipeline did not publish a new release.' >&2
exit 1
fi
+422
View File
@@ -0,0 +1,422 @@
name: Release Aether
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
packages: write
concurrency:
group: release-aether-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
GHCR_IMAGE: fawney19/aether
DOCKERHUB_IMAGE: fawney19/aether
jobs:
preflight:
name: Release preflight
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.classify.outputs.publish }}
version_tag: ${{ steps.classify.outputs.version_tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
make_latest: ${{ steps.classify.outputs.make_latest }}
steps:
- name: Classify release tag
id: classify
shell: bash
run: |
set -euo pipefail
echo "publish=false" >> "${GITHUB_OUTPUT}"
echo "version_tag=" >> "${GITHUB_OUTPUT}"
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Manual release build; publish jobs will be skipped."
exit 0
fi
tag="${GITHUB_REF_NAME}"
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then
echo "Unsupported release tag: ${tag}" >&2
echo "Expected vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N." >&2
exit 1
fi
echo "version_tag=${tag}" >> "${GITHUB_OUTPUT}"
if [[ "${tag}" == *-* ]]; then
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
else
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
fi
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
echo "publish=true" >> "${GITHUB_OUTPUT}"
else
echo "Manual release build for ${tag}; publish jobs will be skipped."
fi
frontend:
name: Build frontend
needs: preflight
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
frontend/package-lock.json
aether-vscodex/web/package-lock.json
- name: Build aether-vscodex web
working-directory: aether-vscodex/web
run: |
npm ci
npm run build
- name: Install & build
working-directory: frontend
run: |
npm ci
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v5
with:
name: frontend-dist
path: frontend/dist/
if-no-files-found: error
retention-days: 1
vscodex:
name: Build VS Code Codex extension
needs: preflight
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
aether-vscodex/package-lock.json
aether-vscodex/web/package-lock.json
aether-vscodex/vscode-extension/package-lock.json
- name: Install module test dependencies
working-directory: aether-vscodex
run: npm ci
- name: Build the embedded Web UI
working-directory: aether-vscodex/web
run: |
npm ci
npm run build
- name: Install extension dependencies
working-directory: aether-vscodex/vscode-extension
run: npm ci
- name: Check and compile the extension
working-directory: aether-vscodex/vscode-extension
run: |
npm run check
npm run build
- name: Run module tests
working-directory: aether-vscodex
run: npm test
- name: Run Web UI tests
working-directory: aether-vscodex/web
run: npm test
- name: Package VSIX
working-directory: aether-vscodex/vscode-extension
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository
source_vsix="codex-remote-collab-${version}.vsix"
test -f "${source_vsix}"
mv "${source_vsix}" "aether-vscodex-${version}.vsix"
unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null
- name: Upload VSIX artifact
uses: actions/upload-artifact@v5
with:
name: aether-vscodex-vsix
path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix
if-no-files-found: error
retention-days: 7
build:
name: Build ${{ matrix.name }}
needs: preflight
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-musl
platform: linux
arch: amd64
os: ubuntu-latest
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-musl
platform: linux
arch: arm64
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
platform: macos
arch: amd64
os: macos-15-intel
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
platform: macos
arch: arm64
os: macos-15
use_cross: false
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: release-${{ matrix.target }}
workspaces: . -> target
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build
env:
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
AETHER_BUILD_TYPE: release
CARGO_TERM_COLOR: always
shell: bash
run: |
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
else
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
fi
- name: Upload binary artifact
uses: actions/upload-artifact@v5
with:
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
path: target/${{ matrix.target }}/release/aether-gateway
if-no-files-found: error
retention-days: 1
docker:
name: Docker multi-arch
needs: [preflight, frontend, build]
if: needs.preflight.outputs.publish == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
path: artifacts
- name: Prepare dist layout
run: |
mkdir -p dist
cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
cp -r artifacts/frontend-dist dist/frontend
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
docker.io/${{ env.DOCKERHUB_IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.preflight.outputs.make_latest == 'true' }}
type=raw,value=latest,enable=${{ needs.preflight.outputs.make_latest == 'true' }}
type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta.') }}
type=raw,value=rc,enable=${{ contains(github.ref_name, '-rc.') }}
type=sha,prefix=
flavor: |
latest=false
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
package:
name: Release tarballs
needs: [preflight, frontend, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
path: artifacts
- name: Build release packages
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME}"
SOURCE_REF="${GITHUB_REF_NAME}"
else
VERSION="snapshot-${GITHUB_SHA::7}"
SOURCE_REF="${GITHUB_SHA}"
fi
mkdir -p package release-assets
for platform in linux macos; do
for arch in amd64 arm64; do
bundle="aether-${VERSION}-${platform}-${arch}"
root="package/${bundle}"
mkdir -p \
"${root}/bin" \
"${root}/frontend"
install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
cp -R artifacts/frontend-dist/. "${root}/frontend/"
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > "${root}/install.sh"
chmod 0755 "${root}/install.sh"
install -m 0755 update.sh "${root}/update.sh"
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
install -m 0644 .env.example "${root}/.env.example"
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
install -m 0644 README.md "${root}/README.md"
install -m 0644 LICENSE "${root}/LICENSE"
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
done
done
sed \
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > release-assets/install.sh
chmod +x release-assets/install.sh
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
- name: Upload release package artifact
uses: actions/upload-artifact@v5
with:
name: release-assets
path: release-assets/*
if-no-files-found: error
retention-days: 7
github-release:
name: GitHub Release assets
needs: [preflight, docker, package, vscodex]
if: needs.preflight.outputs.publish == 'true'
runs-on: ubuntu-latest
steps:
- name: Download release package artifact
uses: actions/download-artifact@v5
with:
name: release-assets
path: release-assets
- name: Download VSIX artifact
uses: actions/download-artifact@v5
with:
name: aether-vscodex-vsix
path: release-assets
- name: Delete stale draft releases for tag
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
if [[ -z "${draft_ids}" ]]; then
echo "No stale draft releases for ${RELEASE_TAG}"
exit 0
fi
while IFS= read -r release_id; do
[[ -z "${release_id}" ]] && continue
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
done <<< "${draft_ids}"
- name: Publish GitHub Release assets
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
prerelease: ${{ needs.preflight.outputs.prerelease }}
make_latest: ${{ needs.preflight.outputs.make_latest }}
files: |
release-assets/*.tar.gz
release-assets/SHA256SUMS
release-assets/install.sh
release-assets/*.vsix
+687
View File
@@ -0,0 +1,687 @@
name: Rust CI
on:
workflow_call:
push:
branches:
- master
- main
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- "apps/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/nightly.yml"
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- "apps/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/nightly.yml"
concurrency:
group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_PROFILE_TEST_DEBUG: 0
CARGO_TERM_COLOR: always
jobs:
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: rustfmt
- name: Format
run: cargo fmt --all --check
clippy_gateway:
name: Clippy (Gateway)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Clippy
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
clippy_data:
name: Clippy (Data)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Clippy
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo clippy -p aether-data --all-targets -- -D warnings
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
clippy_rest:
name: Clippy (Workspace Rest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Clippy
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
clippy:
name: Clippy
runs-on: ubuntu-latest
needs:
- clippy_gateway
- clippy_data
- clippy_rest
if: ${{ always() }}
steps:
- name: Verify clippy jobs
run: |
if [ "${{ needs.clippy_gateway.result }}" != "success" ] || \
[ "${{ needs.clippy_data.result }}" != "success" ] || \
[ "${{ needs.clippy_rest.result }}" != "success" ]; then
echo "Clippy failed"
exit 1
fi
test_gateway:
name: Test (Gateway)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Setup mold
uses: rui314/setup-mold@v1
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Test lib
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
RUST_MIN_STACK: "16777216"
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: cargo nextest run -p aether-gateway --lib
- name: Test bins
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
RUST_MIN_STACK: "16777216"
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: cargo nextest run -p aether-gateway --bins
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test_data:
name: Test (Data)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo nextest run -p aether-data
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
check_data_features:
name: Check (Data Feature - ${{ matrix.feature }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
feature:
- postgres
- mysql
- sqlite
- all-drivers
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Check selected data driver
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test_rest:
name: Test (Workspace Rest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test_data_adapters:
name: Test (Data Adapter - ${{ matrix.package }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- aether-data-postgres
- aether-data-mysql
- aether-data-sqlite
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Test adapter
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo nextest run -p ${{ matrix.package }}
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
check_integration_scenarios:
name: Test (Integration Scenarios)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Test scenario binaries and end-to-end suites
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo test -p aether-integration-tests --bins --tests
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test:
name: Test
runs-on: ubuntu-latest
needs:
- test_gateway
- test_data
- check_data_features
- test_rest
- test_data_adapters
- check_integration_scenarios
if: ${{ always() }}
steps:
- name: Verify test jobs
run: |
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
[ "${{ needs.test_data.result }}" != "success" ] || \
[ "${{ needs.check_data_features.result }}" != "success" ] || \
[ "${{ needs.test_rest.result }}" != "success" ] || \
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
echo "Tests failed"
exit 1
fi
data_db_smoke_sqlite:
name: Data DB Smoke (SQLite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Run SQLite data smoke tests
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo test -p aether-data --all-features sqlite --lib
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
data_db_smoke_postgres:
name: Data DB Smoke (Postgres)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: aether_test
POSTGRES_USER: aether
POSTGRES_PASSWORD: aether
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -h 127.0.0.1 -U aether -d aether_test"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Add PostgreSQL server binaries to PATH
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
- name: Run Postgres migration smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
- name: Run Postgres provider metadata migration smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
- name: Run Postgres API key lifecycle tests
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
run: |
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
- name: Run Postgres core export smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
- name: Run SQLite-to-Postgres import smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
data_db_smoke_mysql:
name: Data DB Smoke (MySQL)
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: aether_test
MYSQL_USER: aether
MYSQL_PASSWORD: aether
MYSQL_ROOT_PASSWORD: aether_root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uaether -paether --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Show Rust toolchain
run: rustup show active-toolchain
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust-ci-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Run MySQL migration smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data --all-features mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
- name: Run MySQL usage write smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_and_flushes_counters_when_url_is_set --lib -- --nocapture
- name: Run MySQL usage read smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data-mysql mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
- name: Run MySQL provider catalog smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data-mysql mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
- name: Run MySQL core export smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data --all-features mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
- name: Run MySQL wallet read smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data-mysql mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
- name: Run MySQL wallet daily usage aggregation smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data --all-features mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
- name: Run MySQL stats aggregation smoke test
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
run: cargo test -p aether-data --all-features mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
data_db_smoke:
name: Data DB Smoke
runs-on: ubuntu-latest
needs:
- data_db_smoke_sqlite
- data_db_smoke_postgres
- data_db_smoke_mysql
if: ${{ always() }}
steps:
- name: Verify database smoke jobs
run: |
if [ "${{ needs.data_db_smoke_sqlite.result }}" != "success" ] || \
[ "${{ needs.data_db_smoke_postgres.result }}" != "success" ] || \
[ "${{ needs.data_db_smoke_mysql.result }}" != "success" ]; then
echo "Data DB smoke failed"
exit 1
fi
check:
name: check
runs-on: ubuntu-latest
needs:
- fmt
- clippy
- test
- data_db_smoke
if: ${{ always() }}
steps:
- name: Verify required jobs
run: |
if [ "${{ needs.fmt.result }}" != "success" ] || \
[ "${{ needs.clippy.result }}" != "success" ] || \
[ "${{ needs.test.result }}" != "success" ] || \
[ "${{ needs.data_db_smoke.result }}" != "success" ]; then
echo "Rust CI failed"
exit 1
fi
+19
View File
@@ -1,11 +1,17 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
*.rsa
*_rsa
# AI Assistant Configuration
.codex/
.claude/
.deepseek/
.serena/
.gemini*/
.plans
.playwright-mcp/
### Python ###
*.db
@@ -212,6 +218,10 @@ backups/
# Runtime lock files
.locks/
# Local Rust/Cargo configuration
.cargo/
# Demo and test files
frontend/public/*-demo.html
frontend/public/*-measure.html
@@ -220,14 +230,23 @@ frontend/public/*-firework.svg
# Debug and experimental files
debug_*.html
extracted_*.ts
test.py
# Deploy script cache
.deps-hash
.code-hash
.migration-hash
.hub-hash
# Hub prebuilt binaries
aether-hub/dist/
# Version file (auto-generated by hatch-vcs)
src/_version.py
# Analysis folder (third-party code for reference)
analysis/
new-api/
apps/aether-tunnel/aether-tunnel.toml
# Generated by frontend/scripts/sync-vscodex.mjs.
frontend/public/aether-vscodex/
+2
View File
@@ -0,0 +1,2 @@
[tools]
rust = "latest"
Generated
+6403
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
[workspace]
members = [
"apps/aether-tunnel",
"crates/aether-ai/formats",
"crates/aether-admin",
"crates/aether-admission-core",
"crates/aether-ai/serving",
"crates/aether-pool-core",
"crates/aether-provider/core",
"crates/aether-provider/pool",
"crates/aether-routing-core",
"crates/aether-data/contracts",
"crates/aether-data/adapters/postgres",
"crates/aether-data/adapters/mysql",
"crates/aether-data/adapters/sqlite",
"crates/aether-data/query",
"crates/aether-data/schema",
"crates/aether-dispatch-core",
"crates/aether-cache",
"crates/aether-billing",
"crates/aether-wallet",
"crates/aether-crypto",
"crates/aether-contracts",
"crates/aether-data/runtime",
"crates/aether-model-fetch",
"crates/aether-oauth",
"crates/aether-provider/transport",
"crates/aether-scheduler-core",
"crates/aether-runtime/state",
"crates/aether-task/runtime",
"crates/aether-task/core",
"crates/aether-gateway/frontdoor",
"crates/aether-gateway/control",
"crates/aether-gateway/execution",
"crates/aether-gateway/workers",
"crates/aether-gateway/tunnel",
"crates/aether-testing/loadtools",
"crates/aether-testing/integration",
"crates/aether-usage/core",
"crates/aether-testing/support",
"crates/aether-usage/runtime",
"crates/aether-video-tasks-core",
"apps/aether-gateway",
"crates/aether-http",
"crates/aether-runtime/base",
"crates/aether-testing/testkit",
]
default-members = [
"apps/aether-gateway",
]
resolver = "2"
[workspace.package]
edition = "2021"
license = "LicenseRef-Aether-NonCommercial"
repository = "https://github.com/fawney19/Aether.git"
[workspace.dependencies]
aether-admin = { path = "crates/aether-admin" }
aether-admission-core = { path = "crates/aether-admission-core" }
aether-ai-formats = { path = "crates/aether-ai/formats" }
aether-ai-serving = { path = "crates/aether-ai/serving" }
aether-pool-core = { path = "crates/aether-pool-core" }
aether-provider-core = { path = "crates/aether-provider/core" }
aether-provider-pool = { path = "crates/aether-provider/pool" }
aether-routing-core = { path = "crates/aether-routing-core" }
aether-data-contracts = { path = "crates/aether-data/contracts" }
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
aether-data-mysql = { path = "crates/aether-data/adapters/mysql" }
aether-data-sqlite = { path = "crates/aether-data/adapters/sqlite" }
aether-data-query = { path = "crates/aether-data/query" }
aether-data-schema = { path = "crates/aether-data/schema" }
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
aether-cache = { path = "crates/aether-cache" }
aether-billing = { path = "crates/aether-billing" }
aether-wallet = { path = "crates/aether-wallet" }
aether-crypto = { path = "crates/aether-crypto" }
aether-contracts = { path = "crates/aether-contracts" }
aether-data = { path = "crates/aether-data/runtime" }
aether-model-fetch = { path = "crates/aether-model-fetch" }
aether-oauth = { path = "crates/aether-oauth" }
aether-provider-transport = { path = "crates/aether-provider/transport" }
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
aether-runtime-state = { path = "crates/aether-runtime/state" }
aether-task-runtime = { path = "crates/aether-task/runtime" }
aether-task-core = { path = "crates/aether-task/core" }
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
aether-gateway-control = { path = "crates/aether-gateway/control" }
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
aether-loadtools = { path = "crates/aether-testing/loadtools" }
aether-integration-tests = { path = "crates/aether-testing/integration" }
aether-test-support = { path = "crates/aether-testing/support" }
aether-usage-core = { path = "crates/aether-usage/core" }
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
aether-gateway = { path = "apps/aether-gateway" }
aether-http = { path = "crates/aether-http" }
aether-runtime = { path = "crates/aether-runtime/base" }
aether-testkit = { path = "crates/aether-testing/testkit" }
aes = "0.8"
aes-gcm = "0.10"
async-stream = "0.3"
async-trait = "0.1"
axum = "0.8"
base64 = "0.22"
bcrypt = "0.16"
brotli = "8"
bytes = "1"
cbc = "0.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
crypto_box = { version = "0.9", features = ["seal"] }
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
flate2 = "1"
futures-util = "0.3"
hmac = "0.12"
http = "1"
object_store = { version = "0.12", default-features = false, features = ["aws"] }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
regex = "1"
rustls = { version = "0.23", features = ["ring"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_path_to_error = "0.1"
sha2 = "0.10"
socket2 = "0.6"
tar = "0.4"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] }
webpki-roots = "0.26"
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
wreq-util = "3.0.0-rc.10"
url = "2"
zstd = "0.13"
[profile.dev]
# Keep file/line information for backtraces while avoiding full debug info
# generation on very large crates during local development builds.
debug = "line-tables-only"
[profile.test]
# The gateway test target pulls in a very large in-crate test tree, so use the
# lighter debug format here as well to reduce rustc peak memory.
debug = "line-tables-only"
[profile.release]
lto = "thin"
strip = true
codegen-units = 8
+42 -151
View File
@@ -1,152 +1,43 @@
# 运行镜像:从 base 提取产物到精简运行时
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
# 用于 GitHub Actions CI(官方源)
FROM aether-base:latest AS builder
WORKDIR /app
# 复制前端源码并构建
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
WORKDIR /app
# 运行时依赖(无 gcc/nodejs/npm)
RUN apt-get update && apt-get install -y \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 只复制需要的 Python 可执行文件
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# Nginx 配置模板
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
RUN printf '%s\n' \
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login|auth)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 入口脚本(启动前执行迁移)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084 \
GUNICORN_WORKERS=4
EXPOSE 80
# syntax=docker/dockerfile:1
# Aether Gateway runtime image (cross-compilation)
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
#
# Build context must contain:
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
# dist/frontend/ (npm run build output)
# --- layout stage: create /opt/aether directory structure with symlink ---
# distroless has no shell, so we use busybox to set up the symlink.
FROM busybox:1.37-musl AS layout
ARG TARGETARCH
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
COPY dist/frontend/ /opt/aether/releases/image/frontend/
RUN ln -s /opt/aether/releases/image /opt/aether/current
# --- final stage: distroless runtime ---
FROM gcr.io/distroless/static-debian12
COPY --from=layout /opt/aether /opt/aether
WORKDIR /opt/aether
ENV RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_UPDATE_STRATEGY=docker \
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER root
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
+148 -148
View File
@@ -1,160 +1,160 @@
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# 用于本地/国内服务器部署
FROM aether-base:latest AS builder
# syntax=docker.m.daocloud.io/docker/dockerfile:1
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
WORKDIR /app
ARG RUST_VERSION=1.95.0
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
# 复制前端源码并构建
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 前端构建 ====================
FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
WORKDIR /app/aether-vscodex/web
COPY aether-vscodex/web/package*.json ./
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
npm config set registry https://registry.npmmirror.com && \
npm ci --no-audit --no-fund
COPY aether-vscodex/public /app/aether-vscodex/public
COPY aether-vscodex/web/ ./
RUN npm run build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
npm config set registry https://registry.npmmirror.com && \
npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ==================== Rust gateway 构建 ====================
FROM ${RUST_BASE_IMAGE} AS gateway-base
WORKDIR /build
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段。
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_PROFILE_RELEASE_LTO=thin \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
clang \
cmake \
git \
libclang-dev \
libssl-dev \
lld \
pkg-config \
perl
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
cargo install cargo-chef --locked
FROM gateway-base AS gateway-planner
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN cargo chef prepare --recipe-path recipe.json
FROM gateway-base AS gateway-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
set -eux; \
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
FROM gateway-builder AS runtime-prep
RUN set -eux; \
mkdir -p \
/runtime-root/app/data \
/runtime-root/app/logs \
/runtime-root/etc \
/runtime-root/etc/ssl \
/runtime-root/lib \
/runtime-root/lib64 \
/runtime-root/usr/local/bin; \
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
: > /tmp/runtime-libs.txt; \
: > /tmp/runtime-scan-queue.txt; \
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
while [ -s /tmp/runtime-scan-queue.txt ]; do \
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
sed -i '1d' /tmp/runtime-scan-queue.txt; \
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
[ -n "$lib" ]; \
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
fi; \
done; \
done; \
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
while read -r lib; do \
[ -n "$lib" ]; \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
done < /tmp/runtime-libs.txt; \
for lib in \
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
/lib/x86_64-linux-gnu/libnss_files.so.2 \
/lib/x86_64-linux-gnu/libresolv.so.2; do \
if [ -f "$lib" ]; then \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
fi; \
done; \
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
if [ -f /etc/ssl/openssl.cnf ]; then \
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
fi; \
if [ -f /etc/nsswitch.conf ]; then \
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
fi
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
FROM scratch
# 复制 gateway 二进制
COPY --from=runtime-prep /runtime-root/ /
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
WORKDIR /app
# 运行时依赖(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 只复制需要的 Python 可执行文件
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# Nginx 配置模板
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
RUN printf '%s\n' \
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 入口脚本(启动前执行迁移)
COPY entrypoint.sh /entrypoint.sh
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_UPDATE_STRATEGY=manual \
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
EXPOSE 80
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
+159
View File
@@ -0,0 +1,159 @@
# syntax=docker.m.daocloud.io/docker/dockerfile:1
# Aether 本地发布版联调镜像
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
ARG RUST_VERSION=1.95.0
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
# ==================== 前端构建 ====================
FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
WORKDIR /app/aether-vscodex/web
COPY aether-vscodex/web/package*.json ./
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
npm config set registry https://registry.npmmirror.com && \
npm ci --no-audit --no-fund
COPY aether-vscodex/public /app/aether-vscodex/public
COPY aether-vscodex/web/ ./
RUN npm run build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
npm config set registry https://registry.npmmirror.com && \
npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ==================== Rust gateway 构建 ====================
FROM ${RUST_BASE_IMAGE} AS gateway-base
WORKDIR /build
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_PROFILE_RELEASE_LTO=thin \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
git \
libclang-dev \
libssl-dev \
pkg-config \
perl
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
cargo install cargo-chef --locked
FROM gateway-base AS gateway-planner
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN cargo chef prepare --recipe-path recipe.json
FROM gateway-base AS gateway-builder
ARG AETHER_BUILD_VERSION
ARG AETHER_BUILD_TYPE=release
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION} \
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo build --release --locked -p aether-gateway --features jemalloc && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
FROM gateway-builder AS runtime-prep
RUN set -eux; \
mkdir -p \
/runtime-root/app/data \
/runtime-root/etc \
/runtime-root/etc/ssl \
/runtime-root/lib \
/runtime-root/lib64 \
/runtime-root/usr/lib \
/runtime-root/opt/aether/logs \
/runtime-root/opt/aether/releases/image/bin \
/runtime-root/opt/aether/releases/image/frontend; \
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
: > /tmp/runtime-libs.txt; \
: > /tmp/runtime-scan-queue.txt; \
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
while [ -s /tmp/runtime-scan-queue.txt ]; do \
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
sed -i '1d' /tmp/runtime-scan-queue.txt; \
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
[ -n "$lib" ]; \
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
fi; \
done; \
done; \
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
while read -r lib; do \
[ -n "$lib" ]; \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
done < /tmp/runtime-libs.txt; \
for lib in \
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
/lib/x86_64-linux-gnu/libnss_files.so.2 \
/lib/x86_64-linux-gnu/libresolv.so.2; do \
if [ -f "$lib" ]; then \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
fi; \
done; \
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
if [ -f /etc/ssl/openssl.cnf ]; then \
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
fi; \
if [ -f /etc/nsswitch.conf ]; then \
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
fi
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
# ==================== 运行时镜像 ====================
FROM scratch
COPY --from=runtime-prep /runtime-root/ /
WORKDIR /app
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_BASE_DIR=/opt/aether \
AETHER_UPDATE_STRATEGY=self \
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
-25
View File
@@ -1,25 +0,0 @@
# 构建镜像:编译环境 + 预编译的依赖
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
WORKDIR /app
# 构建工具
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Python 依赖
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
# 前端依赖(只安装,不构建)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci
-28
View File
@@ -1,28 +0,0 @@
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
WORKDIR /app
# 构建工具(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
libpq-dev \
gcc \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# pip 镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# Python 依赖
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
# 前端依赖(只安装,不构建,使用淘宝镜像源)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
+567
View File
@@ -0,0 +1,567 @@
SHELL := /bin/bash
DEV_RUST_LOG := info,executor::candidate_loop=debug,stream::execution=debug
ifeq ($(origin RUST_LOG), command line)
DEV_RUST_LOG := $(RUST_LOG)
endif
export DEV_RUST_LOG
.PHONY: dev dev-backend dev-frontend migration backfill
define DEV_BACKEND_SCRIPT
set -euo pipefail
if [ ! -f .env ]; then
echo "=> 未找到 .env,请先执行: cp .env.example .env"
exit 1
fi
set -a
source .env
set +a
dotenv_has_key() {
local key="$$1"
grep -Eq "^[[:space:]]*$${key}=" .env
}
lowercase() {
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
}
dev_uses_sqlite_database() {
local driver
local url
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
[[ "$${driver}" == "sqlite" || "$${url}" == sqlite:* ]]
}
dev_uses_postgres_database() {
local driver
local url
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
if [[ -z "$${driver}" && -z "$${url}" ]]; then
return 0
fi
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
}
dev_uses_redis_runtime() {
local backend
backend="$$(lowercase "$${AETHER_RUNTIME_BACKEND:-}")"
if [[ "$${backend}" == "memory" ]]; then
return 1
fi
if [[ "$${backend}" == "redis" ]]; then
return 0
fi
if dev_uses_sqlite_database; then
return 1
fi
return 0
}
print_dev_infra_hint() {
echo "=> 本地开发依赖未就绪。"
echo "=> 可手动启动 Postgres / Redis:"
echo "=> docker compose up -d postgres redis"
}
check_postgres_ready() {
local host="$$1"
local port="$$2"
if command -v pg_isready >/dev/null 2>&1; then
pg_isready -h "$${host}" -p "$${port}" >/dev/null 2>&1
return $$?
fi
if command -v nc >/dev/null 2>&1; then
nc -z "$${host}" "$${port}" >/dev/null 2>&1
return $$?
fi
return 0
}
check_redis_ready() {
local host="$$1"
local port="$$2"
local password="$$3"
if command -v redis-cli >/dev/null 2>&1; then
REDISCLI_AUTH="$${password}" redis-cli -h "$${host}" -p "$${port}" ping >/dev/null 2>&1
return $$?
fi
if command -v nc >/dev/null 2>&1; then
nc -z "$${host}" "$${port}" >/dev/null 2>&1
return $$?
fi
return 0
}
is_local_host() {
case "$$1" in
localhost|127.0.0.1|::1)
return 0
;;
esac
return 1
}
ensure_dev_infra() {
local postgres_host="$${DB_HOST:-localhost}"
local postgres_port="$${DB_PORT:-5432}"
local redis_host="$${REDIS_HOST:-localhost}"
local redis_port="$${REDIS_PORT:-6379}"
local redis_password="$${REDIS_PASSWORD:-}"
local need_postgres=false
local need_redis=false
local services=()
if dev_uses_postgres_database; then
if ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
if is_local_host "$${postgres_host}"; then
need_postgres=true
services+=(postgres)
else
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
print_dev_infra_hint
return 1
fi
fi
fi
if dev_uses_redis_runtime; then
if ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
if is_local_host "$${redis_host}"; then
need_redis=true
services+=(redis)
else
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
print_dev_infra_hint
return 1
fi
fi
fi
if [ "$${#services[@]}" -eq 0 ]; then
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "=> 未找到 docker,无法自动启动本地开发依赖。"
print_dev_infra_hint
return 1
fi
echo "=> 本地开发依赖未就绪,正在启动: docker compose up -d $${services[*]}"
if ! docker compose up -d "$${services[@]}"; then
echo "=> docker compose 启动本地开发依赖失败。"
print_dev_infra_hint
return 1
fi
for _ in {1..100}; do
local ready=true
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
ready=false
fi
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
ready=false
fi
if [ "$${ready}" = "true" ]; then
return 0
fi
sleep 0.2
done
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
fi
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
fi
print_dev_infra_hint
return 1
}
print_startup_failure_hint() {
local log_file="$$1"
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
if grep -Eq "database schema is behind" "$${log_file}"; then
echo "=> 检测到数据库 schema 落后,请执行: make migration"
return
fi
if grep -Eq "database backfills are behind" "$${log_file}"; then
echo "=> 检测到待执行 backfills,请执行: make backfill"
return
fi
fi
echo "=> 未识别到明确的修复动作,请根据上面的日志继续排查。"
}
wait_for_startup() {
local pid="$$1"
local timeout_seconds="$$2"
local service_name="$$3"
shift 3
STARTUP_WAIT_EARLY_EXIT=false
local attempts=$$((timeout_seconds * 10))
if [ "$${attempts}" -lt 1 ]; then
attempts=1
fi
for ((i = 0; i < attempts; i++)); do
if "$$@" >/dev/null 2>&1; then
return 0
fi
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
STARTUP_WAIT_EARLY_EXIT=true
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
return 1
fi
sleep 0.1
done
if "$$@" >/dev/null 2>&1; then
return 0
fi
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
STARTUP_WAIT_EARLY_EXIT=true
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
return 1
fi
echo "=> $${service_name} 在 $${timeout_seconds}s 内未通过启动检查。"
echo "=> 如果这是冷编译或存在并发 cargo 构建,可调大启动超时后重试。"
return 1
}
create_gateway_log_file() {
local tmp_root="$${TMPDIR:-/tmp}"
tmp_root="$${tmp_root%/}"
GATEWAY_LOG_DIR="$$(mktemp -d "$${tmp_root}/aether-dev-startup.XXXXXX")"
GATEWAY_LOG_FILE="$${GATEWAY_LOG_DIR}/gateway.log"
: > "$${GATEWAY_LOG_FILE}"
}
cleanup() {
local status="$${1:-0}"
trap - INT TERM EXIT
if [ -n "$${GATEWAY_PID:-}" ]; then
echo ""
echo "=> 停止 aether-gateway..."
kill "$${GATEWAY_PID}" >/dev/null 2>&1 || true
wait "$${GATEWAY_PID}" >/dev/null 2>&1 || true
fi
if [ -n "$${GATEWAY_LOG_FILE:-}" ] && [ -f "$${GATEWAY_LOG_FILE}" ]; then
rm -f "$${GATEWAY_LOG_FILE}"
fi
if [ -n "$${GATEWAY_LOG_DIR:-}" ] && [ -d "$${GATEWAY_LOG_DIR}" ]; then
rmdir "$${GATEWAY_LOG_DIR}" >/dev/null 2>&1 || true
fi
exit "$${status}"
}
trap 'cleanup 130' INT
trap 'cleanup 143' TERM
trap 'cleanup $$?' EXIT
export APP_PORT="$${APP_PORT:-8084}"
export RUST_LOG="$${DEV_RUST_LOG}"
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS="$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-180}"
GATEWAY_STARTUP_TIMEOUT_SECONDS="$${GATEWAY_STARTUP_TIMEOUT_SECONDS:-$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}"
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE="$${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}"
if dev_uses_postgres_database; then
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
fi
fi
if dev_uses_redis_runtime; then
export REDIS_URL="redis://:$${REDIS_PASSWORD:-}@$${REDIS_HOST:-localhost}:$${REDIS_PORT:-6379}/0"
if ! dotenv_has_key "AETHER_GATEWAY_DATA_REDIS_URL"; then
export AETHER_GATEWAY_DATA_REDIS_URL="$${REDIS_URL}"
fi
else
unset REDIS_URL
unset AETHER_GATEWAY_DATA_REDIS_URL
fi
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
fi
export DB_POOL_SIZE="$${DB_POOL_SIZE:-5}"
export DB_MAX_OVERFLOW="$${DB_MAX_OVERFLOW:-5}"
export HTTP_MAX_CONNECTIONS="$${HTTP_MAX_CONNECTIONS:-20}"
export HTTP_KEEPALIVE_CONNECTIONS="$${HTTP_KEEPALIVE_CONNECTIONS:-5}"
if ! command -v cargo >/dev/null 2>&1; then
echo "=> 未找到 cargo,无法启动 aether-gateway。请先安装 Rust toolchain。"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "=> 未找到 curl,无法检查 aether-gateway 健康状态。请先安装 curl。"
exit 1
fi
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
export RUSTC_WRAPPER="$$(command -v sccache)"
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
fi
if ! ensure_dev_infra; then
exit 1
fi
GATEWAY_PID=""
GATEWAY_LOG_DIR=""
GATEWAY_LOG_FILE=""
STARTUP_WAIT_EARLY_EXIT=false
create_gateway_log_file
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
echo "=> 日志过滤: $${RUST_LOG}"
echo "=> 执行命令: cargo run -p aether-gateway -- --app-port $${APP_PORT}"
cargo run -p aether-gateway -- --app-port "$${APP_PORT}" > >(
tee -a "$${GATEWAY_LOG_FILE}"
) 2>&1 &
GATEWAY_PID=$$!
if ! wait_for_startup "$${GATEWAY_PID}" "$${GATEWAY_STARTUP_TIMEOUT_SECONDS}" "aether-gateway" curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health"; then
if [ "$${STARTUP_WAIT_EARLY_EXIT}" = "true" ]; then
GATEWAY_PID=""
fi
exit 1
fi
if wait "$${GATEWAY_PID}"; then
gateway_exit_code=0
else
gateway_exit_code=$$?
fi
GATEWAY_PID=""
if [ "$${gateway_exit_code}" -ne 130 ] && [ "$${gateway_exit_code}" -ne 143 ]; then
echo "=> aether-gateway 运行失败并已退出,请检查上面的日志。"
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
fi
exit "$${gateway_exit_code}"
endef
export DEV_BACKEND_SCRIPT
define DEV_SCRIPT
set -euo pipefail
backend_pid=""
frontend_pid=""
cleanup() {
local status="$${1:-0}"
trap - INT TERM EXIT
if [ -n "$${backend_pid}" ] || [ -n "$${frontend_pid}" ]; then
echo ""
echo "=> 停止本地开发服务..."
if [ -n "$${backend_pid}" ]; then
kill "$${backend_pid}" >/dev/null 2>&1 || true
wait "$${backend_pid}" >/dev/null 2>&1 || true
fi
if [ -n "$${frontend_pid}" ]; then
kill "$${frontend_pid}" >/dev/null 2>&1 || true
wait "$${frontend_pid}" >/dev/null 2>&1 || true
fi
fi
exit "$${status}"
}
wait_for_backend_ready() {
while :; do
if curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health" >/dev/null 2>&1; then
return 0
fi
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
if wait "$${backend_pid}"; then
status=0
else
status=$$?
fi
if [ "$${status}" -ne 0 ]; then
echo "=> 后端进程已退出 (status $${status})"
else
echo "=> 后端进程已退出"
fi
backend_pid=""
cleanup "$${status}"
fi
sleep 0.2
done
}
trap 'cleanup 130' INT
trap 'cleanup 143' TERM
trap 'cleanup $$?' EXIT
if [ -f .env ]; then
set -a
source .env
set +a
fi
export APP_PORT="$${APP_PORT:-8084}"
echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway -- --app-port $${APP_PORT:-8084}"
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
backend_pid=$$!
echo "=> 等待后端健康检查: http://127.0.0.1:$${APP_PORT}/_gateway/health"
wait_for_backend_ready
echo "=> 启动前端: cd frontend && npm run dev"
( cd frontend && exec npm run dev ) &
frontend_pid=$$!
while :; do
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
if wait "$${backend_pid}"; then
status=0
else
status=$$?
fi
if [ "$${status}" -ne 0 ]; then
echo "=> 后端进程已退出 (status $${status})"
else
echo "=> 后端进程已退出"
fi
backend_pid=""
cleanup "$${status}"
fi
if ! kill -0 "$${frontend_pid}" >/dev/null 2>&1; then
if wait "$${frontend_pid}"; then
status=0
else
status=$$?
fi
if [ "$${status}" -ne 0 ]; then
echo "=> 前端进程已退出 (status $${status})"
else
echo "=> 前端进程已退出"
fi
frontend_pid=""
cleanup "$${status}"
fi
sleep 1
done
endef
export DEV_SCRIPT
define DB_TASK_SCRIPT
set -euo pipefail
if [ -z "$${DB_TASK_FLAG:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
echo "=> 内部错误: DB_TASK_FLAG / DB_TASK_LABEL 未设置"
exit 1
fi
if [ ! -f .env ]; then
echo "=> 未找到 .env,请先执行: cp .env.example .env"
exit 1
fi
set -a
source .env
set +a
dotenv_has_key() {
local key="$$1"
grep -Eq "^[[:space:]]*$${key}=" .env
}
lowercase() {
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
}
uses_postgres_database() {
local driver
local url
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
if [[ -z "$${driver}" && -z "$${url}" ]]; then
return 0
fi
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
}
if uses_postgres_database; then
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
fi
fi
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "=> 未找到 cargo,无法执行 $${DB_TASK_LABEL}。请先安装 Rust toolchain。"
exit 1
fi
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway -- $${DB_TASK_FLAG}"
exec cargo run -p aether-gateway -- "$${DB_TASK_FLAG}"
endef
export DB_TASK_SCRIPT
dev:
@$(SHELL) -euo pipefail -c "$$DEV_SCRIPT"
dev-backend:
@$(SHELL) -euo pipefail -c "$$DEV_BACKEND_SCRIPT"
dev-frontend:
@cd frontend && npm run dev
migration:
@DB_TASK_FLAG=--migrate DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
backfill:
@DB_TASK_FLAG=--apply-backfills DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
+133 -104
View File
@@ -5,12 +5,13 @@
<h1 align="center">Aether</h1>
<p align="center">
<strong>开源 AI API 网关</strong><br>
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入层
<strong>一站式 AI 基础设施平台</strong><br>
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入、格式转换、正/反向代理, 致力于成为用户驱动AI服务的底座
</p>
<p align="center">
<a href="#简介">简介</a> •
<a href="#部署">部署</a> •
<a href="#api-文档">API 文档</a> •
<a href="#环境变量">环境变量</a> •
<a href="#qa">Q&A</a>
</p>
@@ -22,27 +23,15 @@
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
### 页面预览
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/architecture/architecture-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/architecture/architecture-light.svg">
<img src="docs/architecture/architecture-light.svg" width="680" alt="Aether Architecture">
</picture>
</p>
| 首页 | 仪表盘 |
|:---:|:---:|
| ![首页](docs/screenshots/home.png) | ![仪表盘](docs/screenshots/dashboard.png) |
| 健康监控 | 用户管理 |
|:---:|:---:|
| ![健康监控](docs/screenshots/health.png) | ![用户管理](docs/screenshots/users.png) |
| 提供商管理 | 使用记录 |
|:---:|:---:|
| ![提供商管理](docs/screenshots/providers.png) | ![使用记录](docs/screenshots/usage.png) |
| 模型详情 | 关联提供商 |
|:---:|:---:|
| ![模型详情](docs/screenshots/model-detail.png) | ![关联提供商](docs/screenshots/model-providers.png) |
| 链路追踪 | 系统设置 |
|:---:|:---:|
| ![链路追踪](docs/screenshots/tracing.png) | ![系统设置](docs/screenshots/settings.png) |
页面预览: https://fawney19.github.io/Aether/
## 部署
@@ -55,102 +44,142 @@ cd Aether
# 2. 配置环境变量
cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
./generate_keys.sh
# 编辑 .env 设置 ADMIN_PASSWORD
# 3. 部署 / 更新(自动执行数据库迁移)
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
# Postgres + Redis (适用于企业或多人使用)
docker compose pull && docker compose up -d
# Single Node (适用于个人用户或朋友分享)
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
```
### Docker Compose(本地构建镜像)
### 一键更新
Docker Compose 部署后,可在部署目录直接执行:
```bash
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd Aether
./update.sh
```
# 2. 配置环境变量
cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
# 3. 部署 / 更新(自动构建、启动、迁移)
```bash
./update.sh --mode single-node
```
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,并额外挂载一个容器用户可写的目录到 `/opt/aether/logs`。
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
标准 Docker Compose 使用 Docker named volumes 存放 Postgres/Redis/MySQL 数据;Single Node 使用部署目录下的 `./data` 存放 SQLite 数据。
如果是本地源码构建镜像的部署,继续使用:
```bash
./deploy.sh
```
### 本地开发
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
```bash
# 启动依赖
docker compose -f docker-compose.build.yml up -d postgres redis
# 后端
uv sync
./dev.sh
# 前端
cd frontend && npm install && npm run dev
docker compose -f docker-compose.release-local.yml up -d --build
```
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
```bash
docker compose -f docker-compose.release-local.yml down -v
```
可选变量:
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
```bash
git clone https://github.com/fawney19/Aether.git
cd Aether
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
```
### Nightly(每日 main 构建)
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
安装最新 nightly(Linux systemd / macOS launchd + SQLite):
```bash
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --channel nightly
```
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制方式可重新执行上述安装命令升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
## 本地开发
依赖 Docker、Rust toolchain、Node.js 和 make。
```bash
make dev
```
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
## Codex 远程协同
`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。
安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。
## Aether Tunnel (可选)
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
- Docker Compose 部署或下载预编译二进制直接运行
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `tunnel-v*` 制品并向现有 `aether-tunnel.toml` 追加 `[[servers]]`
- 通过 `aether-tunnel setup` 完成交互式配置,自动注册为系统服务
- 详细文档见 [apps/aether-tunnel/README.md](apps/aether-tunnel/README.md)
## API 文档
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
- Responses WebSocket mode: [protocol and Aether behavior](docs/WebSocket-Mode.md)
- WebSocket probes: [Codex](docs/operations/codex-responses-websocket-probe.md) · [OpenAI Responses](docs/operations/openai-responses-websocket-probe.md)
## 环境变量
### 必需配置
| 变量 | 说明 |
|------|------|
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
| `REDIS_PASSWORD` | Redis 密码 |
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key) |
| `ADMIN_EMAIL` | 初始管理员邮箱 |
| `ADMIN_USERNAME` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 初始管理员密码 |
### 可选配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `APP_PORT` | 8084 | 应用端口 |
| `API_KEY_PREFIX` | sk | API Key 前缀 |
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
| `DB_PORT` | 5432 | PostgreSQL 端口 |
| `REDIS_PORT` | 6379 | Redis 端口 |
## Q&A
### Q: 如何开启/关闭请求体记录?
管理员在 **系统设置** 中配置日志记录的详细程度:
| 级别 | 记录内容 |
|------|----------|
| Base | 基本请求信息 |
| Headers | Base + 请求头 |
| Full | Headers + 请求体 |
### Q: 管理员如何给模型配置 1M上下文 / 1H缓存 能力支持?
1. **模型管理**: 给模型设置 1M上下文 / 1H缓存 的能力支持, 并配置好价格
2. **提供商管理**: 给端点添加支持该能力的密钥, 并勾选对应的能力标签
### Q: 用户如何使用 1H缓存?
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡?
在管理后台 **提供商管理** 中切换调度模式:
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 |
| **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key |
### Q: 提供商免费套餐的计费模式会计入成本吗?
> **不会**。免费套餐的计费模式倍率为 0, 产生的记录不计入成本费用。
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`,Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 SQLite 固定 `1/1`,Postgres/MySQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`
- `AETHER_MAX_REQUEST_BODY_MB`:可选的单请求解压后请求体上限;未配置或设为 `0` 时不限制
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:可选的 heartbeat、管理探测等内部整包响应体上限;未配置或设为 `0` 时不限制
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
---
@@ -162,10 +191,10 @@ cd frontend && npm install && npm run dev
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
&nbsp;&nbsp;&nbsp;&nbsp;
<img src="docs/author/qrcode_1770574997172.jpg" width="200" alt="QQ群二维码">
</p>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=date&legend=top-left)](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
+10
View File
@@ -0,0 +1,10 @@
.git
.github
node_modules
test
fixtures
vscode-extension
*.vsix
coverage
data
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
node_modules/
vscode-extension/node_modules/
vscode-extension/dist/
data/
coverage/
*.vsix
.DS_Store
*.log
+26
View File
@@ -0,0 +1,26 @@
FROM node:22-alpine
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8788 \
AETHER_VSCODEX_DATA_DIR=/var/lib/aether-vscodex
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY cloud ./cloud
COPY relay ./relay
COPY public ./public
RUN mkdir -p /var/lib/aether-vscodex && chown -R node:node /var/lib/aether-vscodex /app
USER node
EXPOSE 8788
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8788/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "cloud/server.js"]
+283
View File
@@ -0,0 +1,283 @@
# aether-vscodex
这个项目让浏览器从本机 URL 或 Aether 云端查看、输入并处理 Codex 会话,提供两种
可随时切换的控制模式。默认的**同步模式**通过官方扩展使用的本机 IPC socket,严格
跟随 VS Code Codex 面板当前会话,不启动另一个 `codex` 进程;**异步模式**由伴随扩展
启动独立 app-server,网页可以自行列出、恢复、新建和切换会话。
同一个伴随扩展可同时连接两个互不替代的通道:本机 loopback 控制台和部署在
Aether 中的云端控制台。本机通道默认免密码且只能从本机访问;云端通道使用
Aether 登录鉴权、一次性浏览器票据和独立设备凭据;父页面不会通过协议把 Aether JWT
传给 iframe 或 Node sidecar。iframe 是随 Aether 一起发布的同源受信代码,不应被视为
隔离不受信内容的安全边界。
`vscode-extension/codex-remote-collab-0.4.0.vsix` 安装到 VS Code 后,会作为官方
`openai.chatgpt` Codex 扩展的伴随扩展,并自动托管只监听本机的 relay。开发时仍可
单独运行 `relay/server.js`。不要卸载或替换官方 Codex 扩展。
## 工作方式
```text
官方 VS Code Codex 会话
│ 本机私有 IPC(只在 VS Code 所在机器上)
▼
┌── 本机 relay ── http://127.0.0.1:8787
VS Code aether-vscodex 扩展 ────┤
└── Aether gateway ── 用户/设备隔离的云端 relay
```
控制模式与传输通道是两个独立维度:切换同步/异步不会重连本地或云端 relay。本机和
Aether 控制页连接到同一台 VS Code 主机时,会看到同一个当前模式。
| 控制模式 | 会话所有者 | 网页会话导航 |
| --- | --- | --- |
| 同步 | 官方 VS Code Codex 面板 | 禁止网页自行切换;自动跟随 VS Code |
| 异步 | 扩展启动的独立 app-server | 可列出、恢复、新建和切换会话 |
浏览器的 `operator` 可以发送任务、继续/中断当前 turn,并处理 Codex 的审批、
用户输入和 MCP elicitation;`viewer` 只能查看事件和输出。远程浏览器不接触
VS Code 的 SecretStorage,也不直接连接 IPC socket。
## 前端结构
Aether 页面使用仓库既有的 Vue 3、TypeScript、Vite 和 i18n。独立控制台也提供
Vue/Vite 源码入口,但当前高保真的会话渲染与协议状态机作为兼容运行时保留,构建到
`public/` 后同时供本机 URL 和 Aether 同源 iframe 使用。这样不需要一次性重写并丢失
命令展开、滚动锚点、思考状态、Markdown、子代理、模型和权限菜单等已有行为。
界面支持 `zh-CN` 与 `en-US`。Aether 的语言和深浅色主题会通过经过来源校验的
`postMessage` 同步给 iframe;VS Code 命令与设置说明使用 `package.nls` 本地化。
## Aether 云端部署
云端模式由 Aether gateway 和独立 Node sidecar 组成。sidecar 只在 Compose 内网暴露
8788,公网的 HTTP、配对交换和 WebSocket 都经 Aether gateway:
```text
GET /api/users/me/vscodex/devices
POST /api/users/me/vscodex/pairings
DELETE /api/users/me/vscodex/devices/:device_id
POST /api/users/me/vscodex/ws-tickets
POST /api/vscodex/pair
WS /api/vscodex/ws
```
生成至少 32 字节的内部令牌,并按 Aether 的公开 HTTPS 地址设置变量:
```sh
export AETHER_VSCODEX_INTERNAL_TOKEN="$(openssl rand -base64 32)"
export AETHER_VSCODEX_PUBLIC_WS_URL="wss://aether.example.com/api/vscodex/ws"
export AETHER_VSCODEX_ALLOWED_ORIGINS="https://aether.example.com"
docker compose \
-f docker-compose.yml \
-f docker-compose.local.yml \
-f aether-vscodex/docker-compose.aether.yml \
up -d --build
```
源码部署必须包含 `docker-compose.local.yml`,以保证 gateway、前端和 sidecar 来自同一份
checkout。使用发布镜像时可以去掉该文件,但 `APP_IMAGE` 必须固定为包含相同
`aether-vscodex` 协议版本的 Aether 镜像,不能把当前 sidecar 与旧的 `latest` gateway 混用。
首次使用源码 Compose 前先构建控制台;正式 Aether 发布流程与 Dockerfile 已自动执行
同一步骤:
```sh
npm --prefix aether-vscodex/web ci
npm --prefix aether-vscodex/web run build
```
第一阶段 sidecar 是有状态单副本:设备凭据的 scrypt 哈希保存在
`vscodex_data`,短期配对码、60 秒一次性浏览器票据和在线房间保存在内存。不要在未引入
共享连接目录前横向扩容 sidecar。
登录 Aether 后打开“Codex 远程控制”,生成一次性配对码。然后在 VS Code 命令面板执行
**Codex Remote: Pair with Aether**,填写 Aether 地址和配对码。插件会把设备凭据写入
VS Code SecretStorage,并同时保持本机控制台连接。
## 快速开始
前提:Node.js 20+;官方 `openai.chatgpt` VS Code 扩展已安装并登录;目标会话
已经在 VS Code 的 Codex 面板中打开。VS Code 和 relay 必须以同一个操作系统用户
运行,因为 IPC socket 是本机文件。
1. 安装依赖并构建伴随扩展:
```sh
npm --prefix vscode-extension install
npm --prefix vscode-extension run build
```
本机 `ws://` 地址会由扩展自动启动 relay;loopback 模式默认不需要 token,且
`host` 模式不会启动 `codex app-server`。
2. 安装 `vscode-extension/codex-remote-collab-0.4.0.vsix`(或在扩展目录先
`npm run build` 再用 `npx --yes @vscode/vsce package` 打包),然后在 VS Code
执行 **Developer: Reload Window**。
3. 在 VS Code 设置中填写:
```json
{
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
"codexRemoteCollab.controlMode": "sync",
"codexRemoteCollab.autoDiscoverThread": true,
"codexRemoteCollab.autoStart": true
}
```
4. 执行一次 **Developer: Reload Window** 后,扩展会自动找到最近的、仍由官方
VS Code Codex owner 持有的会话,并把已有输出同步到 relay;如果没有自动启动,
无需手动启动或断开。右下角状态项只用于显示状态并打开 Web。需要精确指定会话时,执行
**Codex Remote: Set Existing Thread ID**;留空则恢复自动发现。
官方 Codex 面板切换会话时,Web 默认会在新会话快照就绪后自动跟随;正在执行或等待
授权的旧会话会先保持附着,结束后再安全切换。
5. 浏览器打开 `http://127.0.0.1:8787`,页面会自动以本机 operator 身份连接,
不需要输入密码。
如果页面显示“等待 VS Code 主机连接”,先确认 relay 地址与扩展设置的端口完全一致,
然后在 VS Code 执行一次 **Developer: Reload Window**。同步模式必须在官方 Codex
面板已经打开至少一个会话后才能发现 owner;通常不需要手工填写
`codexRemoteCollab.threadId`,留空会自动选择最近的可用会话。若之前填写过已经关闭的
thread ID,清空该设置后再重载窗口。
### 发布与下载插件
正式发布时不需要用户在本地编译。仓库的 `.github/workflows/release.yml` 在推送
`vX.Y.Z`、`vX.Y.Z-beta.N` 或 `vX.Y.Z-rc.N` 标签时,会在 GitHub Actions 中完成 Web
前端构建、扩展编译和 VSIX 打包,并把
`aether-vscodex-<extension-version>.vsix` 附加到对应的 GitHub Release。用户从 Release
页面下载该 VSIX,在 VS Code 的扩展视图中选择“从 VSIX 安装...”即可;安装后执行一次
**Developer: Reload Window**。
手动运行该 workflow 时,VSIX 会作为 `aether-vscodex-vsix` Actions artifact 提供下载,
但不会创建 GitHub Release。源码目录中的 VSIX 只用于本地开发验证,不是用户发布渠道。
如果命令面板提示 `command 'codexRemoteCollab.start' not found`,通常是旧版
VSIX 激活失败(旧包可能没有包含 `ws` 运行依赖)。请安装当前的
`codex-remote-collab-0.4.0.vsix` 并使用 `--force` 覆盖旧版本,然后执行一次
**Developer: Reload Window**:
```sh
code --install-extension vscode-extension/codex-remote-collab-0.4.0.vsix --force
```
也可以在 **Output → Codex Remote Collaboration** 中确认没有
`Cannot find module 'ws'`;出现该错误时,说明扩展尚未成功激活。
网页现在按官方 Codex Webview 的会话模型展示:历史和实时输出在中间消息流,用户、
助手、reasoning、命令输出分别投影为对应的消息项;助手内容支持安全的 Markdown、
代码块和复制操作,reasoning/命令活动可折叠。底部 composer 使用可编辑富文本区域,
回车发送、Shift+Enter 换行;审批和用户输入会以内嵌 card 出现在会话流中,支持风险
标记、输入控件、授权范围和明确的允许/拒绝动作。附着适配器会额外发送可选的
`messages` 角色投影,旧版 host 没有该字段时网页仍回退到纯文本快照。
页面打开后自动连接并在断线后重连,不再需要手动点击“连接”或“断开”。同步模式下
会话列表、返回历史和新建入口会被禁用,所有输入都发送到 VS Code 当前会话。这里复刻的是从本机已安装
官方 bundle 审计出的布局、状态和交互;官方 bundle 依赖 VS Code 私有 Webview API,
不能安全地直接作为 iframe 嵌入浏览器。
底部的“同步 / 异步”分段控件发送 `control/mode/set`。当前 turn 正在执行或存在待处理
授权、用户输入时,主机拒绝切换;候选适配器启动失败时保留原模式和原会话。切入异步
模式后,页面顶部会恢复会话历史、新建和选择入口;`session/list` 映射到
`thread/list`,选择会话使用 `thread/resume` 并水合完整历史,新建会话使用
`thread/start`。切回同步模式会关闭独立 app-server,并重新以 VS Code 面板为唯一
会话导航来源。
### 认证(可选)
如果以后需要保护 relay,可显式开启认证;本机流程默认不需要这些变量:
```sh
CODEX_REMOTE_AUTH=required \
CODEX_REMOTE_HOST_TOKEN='host-only-secret' \
CODEX_REMOTE_TOKEN='browser-operator-secret' \
CODEX_REMOTE_VIEW_TOKEN='browser-viewer-secret' \
CODEX_REMOTE_MODE=host npm start
```
认证开启后,Host token 填在 VS Code 扩展中,Operator/Viewer token 填在浏览器中。
## `spawn codex ENOENT` 是什么
这个错误只表示某处正在尝试启动**独立**的 `codex app-server`,但 VS Code 图形
进程的 `PATH` 找不到可执行文件。对于本项目默认的同步模式,不会调用
`spawn codex`,因此不需要通过设置 `codexCommand` 来修复它。
只有切换到异步模式(或仍使用旧版兼容设置)才需要独立可执行文件:
```json
"codexRemoteCollab.controlMode": "async"
```
扩展会优先解析 `codexRemoteCollab.codexCommand`,并可回退到官方 Codex 扩展内置的
可执行文件;`codexRemoteCollab.codexArgs` 默认是 `["app-server", "--stdio"]`。
旧 `mode=attach/spawn` 会分别迁移为 `sync/async`。
## Relay 模式
### `host`(推荐)
relay 只负责认证、事件缓存和转发;VS Code 扩展通过私有 IPC 附着官方 Codex
会话。必须先打开目标会话;本机 loopback 默认不需要 host token,只有显式开启认证时
才把 host token 提供给扩展。
### `embedded`(旧的独立进程模式)
只有显式设置 `CODEX_REMOTE_MODE=embedded` 时,relay 才会启动自己的
`codex app-server --stdio`,适合测试页面和公开 app-server 协议;它与 VS Code
当前会话无关:
```sh
CODEX_REMOTE_MODE=embedded CODEX_CWD="$PWD" npm start
```
`CODEX_BIN` 可指定独立进程的可执行文件;`CODEX_ARGS_JSON` 可覆盖其参数。不要
把这些设置误认为 attach 模式的必要配置。
## HTTP API
认证开启时,除 `/api/health` 外的 `/api/*` 都需要
`Authorization: Bearer <operator-or-viewer-token>` 或 `X-Codex-Token`;本机免认证
模式下 loopback 请求直接作为 operator 处理。
```text
GET /api/health
GET /api/state
GET /api/events?fromSeq=0
POST /api/command {"commandId":"...","method":"turn/start","params":{...}}
POST /api/respond {"requestId":"...","result":{...}}
```
host 模式下,同步控制会拒绝 `thread/start` 和网页会话导航;异步控制会把它们转给
独立 app-server。浏览器使用 `threadId` 发送 `turn/start`、`turn/steer` 或
`turn/interrupt`。认证开启时写操作和
响应请求必须使用 operator token;本机免认证模式下 loopback operator 可直接操作。
## 私有协议和限制
- IPC follower 协议是官方 VS Code 扩展的私有、带版本号实现,不是公开 API;官方
扩展升级后可能需要同步适配。启用 `codexRemoteCollab.ipcStrictVersions`
时,未知 stream 版本会让连接报错而不是猜测执行。
- 自动发现只把本地 rollout 元数据当作候选,最终仍通过 IPC owner discovery
验证;生产或多会话场景建议设置明确的 `threadId`。
- relay 默认只监听 loopback,且 loopback 默认免认证;这意味着同一台机器上能访问
loopback 的本地进程都可能控制会话,不要把它反向代理或暴露到外部。如果开启 token
认证,token 是 bearer secret。高风险授权默认被 host policy 拒绝,只有显式设置
`codexRemoteCollab.allowHighRiskApprovals=true` 才允许。
- 输出会做常见 token/密码脱敏,但不能识别所有秘密;不要把凭据发送给 Codex。
- 当前 UI 控制一个 host 会话,不提供多人同时编辑或文件同步。
## 测试
根目录测试使用假的 stdio app-server,不会向真实 Codex 发送任务:
```sh
npm test
cd vscode-extension && npm run check && npm run build
```
要验证真实附着,只读地打开官方 VS Code 会话后启动 bridge;不要在验证脚本中
调用 `turn/start`,除非你确实要向该会话发送任务。
+727
View File
@@ -0,0 +1,727 @@
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const { URL } = require("node:url");
const { WebSocket, WebSocketServer } = require("ws");
const { CodexRelay } = require("../relay/server.js");
const MAX_JSON_BYTES = 64 * 1024;
const MAX_WS_BYTES = 16 * 1024 * 1024;
const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
const DEFAULT_TICKET_TTL_MS = 60 * 1000;
const DEFAULT_ROOM_IDLE_MS = 30 * 60 * 1000;
class DeviceStore {
constructor(filePath) {
this.filePath = filePath;
this.data = { version: 1, devices: [] };
this.load();
}
load() {
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
if (parsed?.version !== 1 || !Array.isArray(parsed.devices)) throw new Error("unsupported device store format");
this.data = parsed;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
this.persist();
}
}
list(userId, connectedDeviceIds = new Set()) {
return this.data.devices
.filter((device) => device.user_id === userId && !device.revoked_at)
.map((device) => publicDevice(device, connectedDeviceIds.has(device.id)));
}
create(userId, name) {
const id = crypto.randomUUID();
const secret = crypto.randomBytes(32).toString("base64url");
const salt = crypto.randomBytes(16).toString("base64url");
const now = new Date().toISOString();
const device = {
id,
user_id: userId,
name: normalizeName(name),
secret_salt: salt,
secret_hash: deriveSecret(secret, salt),
created_at: now,
last_seen_at: null,
revoked_at: null,
};
this.data.devices.push(device);
this.persist();
return { device: publicDevice(device, false), token: `avx1.${id}.${secret}` };
}
authenticate(token) {
const parsed = parseDeviceToken(token);
if (!parsed) return null;
const device = this.data.devices.find((candidate) => candidate.id === parsed.id && !candidate.revoked_at);
if (!device) return null;
const actual = Buffer.from(deriveSecret(parsed.secret, device.secret_salt), "base64url");
const expected = Buffer.from(device.secret_hash, "base64url");
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null;
return device;
}
get(userId, deviceId) {
return this.data.devices.find((device) => device.user_id === userId && device.id === deviceId && !device.revoked_at) || null;
}
touch(deviceId) {
const device = this.data.devices.find((candidate) => candidate.id === deviceId && !candidate.revoked_at);
if (!device) return;
device.last_seen_at = new Date().toISOString();
this.persist();
}
revoke(userId, deviceId) {
const device = this.get(userId, deviceId);
if (!device) return false;
device.revoked_at = new Date().toISOString();
this.persist();
return true;
}
persist() {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
const temporary = `${this.filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(temporary, this.filePath);
}
}
class EphemeralCredentials {
constructor(options = {}) {
this.pairingTtlMs = options.pairingTtlMs || DEFAULT_PAIRING_TTL_MS;
this.ticketTtlMs = options.ticketTtlMs || DEFAULT_TICKET_TTL_MS;
this.pairings = new Map();
this.tickets = new Map();
}
createPairing(userId, requestedName) {
const code = pairingCode();
const record = {
id: crypto.randomUUID(),
code,
user_id: userId,
requested_name: normalizeName(requestedName),
expires_at_ms: Date.now() + this.pairingTtlMs,
};
this.pairings.set(normalizePairingCode(code), record);
return record;
}
consumePairing(code) {
const key = normalizePairingCode(code);
const record = this.pairings.get(key);
this.pairings.delete(key);
if (!record || record.expires_at_ms <= Date.now()) return null;
return record;
}
createTicket(userId, deviceId) {
const ticket = `avt1.${crypto.randomBytes(32).toString("base64url")}`;
this.tickets.set(ticket, {
user_id: userId,
device_id: deviceId,
expires_at_ms: Date.now() + this.ticketTtlMs,
});
return ticket;
}
consumeTicket(ticket) {
const record = this.tickets.get(ticket);
this.tickets.delete(ticket);
if (!record || record.expires_at_ms <= Date.now()) return null;
return record;
}
cleanup() {
const now = Date.now();
for (const [key, record] of this.pairings) if (record.expires_at_ms <= now) this.pairings.delete(key);
for (const [key, record] of this.tickets) if (record.expires_at_ms <= now) this.tickets.delete(key);
}
}
class RoomManager {
constructor(options = {}) {
this.rooms = new Map();
this.pendingRooms = new Map();
this.revokedRoomKeys = new Set();
this.idleMs = options.idleMs || DEFAULT_ROOM_IDLE_MS;
}
key(userId, deviceId) {
return `${encodeURIComponent(userId)}:${deviceId}`;
}
async get(userId, deviceId) {
const key = this.key(userId, deviceId);
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
let room = this.rooms.get(key);
if (!room && this.pendingRooms.has(key)) room = await this.pendingRooms.get(key);
if (!room) {
const creating = this.createRoom(key, userId, deviceId);
this.pendingRooms.set(key, creating);
try {
room = await creating;
} finally {
this.pendingRooms.delete(key);
}
}
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
room.lastActiveMs = Date.now();
return room;
}
async createRoom(key, userId, deviceId) {
const hostToken = randomToken();
const operatorToken = randomToken();
const relay = new CodexRelay({
host: "127.0.0.1",
port: 0,
mode: "host",
authRequired: true,
hostToken,
operatorToken,
viewerToken: randomToken(),
});
await relay.start();
if (this.revokedRoomKeys.has(key)) {
await relay.stop().catch(() => undefined);
throw httpError(401, "device revoked");
}
const address = relay.address();
const room = {
key,
userId,
deviceId,
relay,
hostToken,
operatorToken,
baseUrl: `ws://127.0.0.1:${address.port}`,
connections: 0,
lastActiveMs: Date.now(),
};
this.rooms.set(key, room);
return room;
}
connectedDeviceIds(userId) {
return new Set([...this.rooms.values()]
.filter((room) => room.userId === userId && room.relay.state.hostConnected)
.map((room) => room.deviceId));
}
retain(room) {
room.connections += 1;
room.lastActiveMs = Date.now();
}
release(room) {
room.connections = Math.max(0, room.connections - 1);
room.lastActiveMs = Date.now();
}
async cleanup() {
const now = Date.now();
for (const [key, room] of this.rooms) {
if (room.connections > 0 || now - room.lastActiveMs < this.idleMs) continue;
this.rooms.delete(key);
await room.relay.stop();
}
}
async revoke(userId, deviceId) {
const key = this.key(userId, deviceId);
this.revokedRoomKeys.add(key);
const pending = this.pendingRooms.get(key);
if (pending) await pending.catch(() => undefined);
const room = this.rooms.get(key);
if (!room) return;
this.rooms.delete(key);
await room.relay.stop();
}
async stop() {
await Promise.allSettled([...this.pendingRooms.values()]);
this.pendingRooms.clear();
const rooms = [...this.rooms.values()];
this.rooms.clear();
this.revokedRoomKeys.clear();
await Promise.allSettled(rooms.map((room) => room.relay.stop()));
}
}
class AetherVscodexCloudServer {
constructor(options = {}) {
this.host = options.host || process.env.HOST || "127.0.0.1";
this.port = parsePort(options.port ?? process.env.PORT, 8788);
this.internalToken = options.internalToken || process.env.AETHER_VSCODEX_INTERNAL_TOKEN || "";
this.publicWsUrl = options.publicWsUrl || process.env.AETHER_VSCODEX_PUBLIC_WS_URL || "";
this.allowedOrigins = normalizeOrigins(options.allowedOrigins ?? process.env.AETHER_VSCODEX_ALLOWED_ORIGINS);
const dataDir = options.dataDir || process.env.AETHER_VSCODEX_DATA_DIR || path.join(process.cwd(), "data");
this.store = options.store || new DeviceStore(path.join(dataDir, "devices.json"));
this.credentials = options.credentials || new EphemeralCredentials(options);
this.rooms = options.rooms || new RoomManager(options);
this.exchangeAttempts = new Map();
this.httpServer = null;
this.wsServer = null;
this.cleanupTimer = null;
}
async start() {
if (!this.internalToken) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN is required");
if (Buffer.byteLength(this.internalToken, "utf8") < 24) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN must contain at least 24 bytes");
if (!this.publicWsUrl) throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL is required");
validatePublicWsUrl(this.publicWsUrl);
if (!isLoopbackHost(this.host) && this.allowedOrigins.size === 0) {
throw new Error("AETHER_VSCODEX_ALLOWED_ORIGINS is required when binding outside loopback");
}
this.httpServer = http.createServer((request, response) => {
void this.handleHttp(request, response).catch((error) => {
jsonResponse(response, error.statusCode || 500, { error: error.expose ? error.message : "internal server error" });
});
});
this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_BYTES });
this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head));
this.cleanupTimer = setInterval(() => {
this.credentials.cleanup();
this.cleanupExchangeAttempts();
void this.rooms.cleanup();
}, 30_000);
this.cleanupTimer.unref();
await new Promise((resolve, reject) => {
const onError = (error) => reject(error);
this.httpServer.once("error", onError);
this.httpServer.listen(this.port, this.host, () => {
this.httpServer.off("error", onError);
resolve();
});
});
return this.address();
}
address() {
const address = this.httpServer.address();
if (!address || typeof address === "string") return { host: this.host, port: this.port };
return { host: address.address, port: address.port };
}
async stop() {
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
if (this.wsServer) {
for (const client of this.wsServer.clients) client.close(1001, "server shutting down");
await new Promise((resolve) => this.wsServer.close(() => resolve()));
}
if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve()));
this.wsServer = null;
this.httpServer = null;
await this.rooms.stop();
}
async handleHttp(request, response) {
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
jsonResponse(response, 200, { ok: true, service: "aether-vscodex", mode: "single-replica" });
return;
}
if (request.method === "POST" && requestUrl.pathname === "/v1/pairings/exchange") {
this.enforceExchangeRate(request);
const body = await readJson(request);
const pairing = this.credentials.consumePairing(body.code);
if (!pairing) throw httpError(400, "invalid or expired pairing code");
const created = this.store.create(pairing.user_id, body.name || pairing.requested_name);
jsonResponse(response, 201, {
device_id: created.device.id,
device_name: created.device.name,
device_token: created.token,
ws_url: this.publicWsUrl,
});
return;
}
const match = requestUrl.pathname.match(/^\/internal\/v1\/users\/([^/]+)\/(devices|pairings|ws-tickets)(?:\/([^/]+))?$/);
if (!match) {
jsonResponse(response, 404, { error: "not found" });
return;
}
this.requireInternalAuth(request);
const userId = decodeURIComponent(match[1]);
const resource = match[2];
const resourceId = match[3] ? decodeURIComponent(match[3]) : null;
if (!userId || userId.length > 256) throw httpError(400, "invalid user id");
if (request.method === "GET" && resource === "devices" && !resourceId) {
jsonResponse(response, 200, { devices: this.store.list(userId, this.rooms.connectedDeviceIds(userId)) });
return;
}
if (request.method === "POST" && resource === "pairings" && !resourceId) {
const body = await readJson(request);
const pairing = this.credentials.createPairing(userId, body.name);
jsonResponse(response, 201, {
pairing_id: pairing.id,
code: pairing.code,
expires_at: new Date(pairing.expires_at_ms).toISOString(),
});
return;
}
if (request.method === "DELETE" && resource === "devices" && resourceId) {
if (!this.store.revoke(userId, resourceId)) throw httpError(404, "device not found");
await this.rooms.revoke(userId, resourceId);
response.writeHead(204, { "Cache-Control": "no-store" });
response.end();
return;
}
if (request.method === "POST" && resource === "ws-tickets" && !resourceId) {
const body = await readJson(request);
const deviceId = typeof body.device_id === "string" ? body.device_id : "";
if (!deviceId || !this.store.get(userId, deviceId)) throw httpError(404, "device not found");
jsonResponse(response, 201, {
ticket: this.credentials.createTicket(userId, deviceId),
ws_url: "/api/vscodex/ws",
expires_in: Math.floor(this.credentials.ticketTtlMs / 1000),
});
return;
}
jsonResponse(response, 405, { error: "method not allowed" }, { Allow: allowedMethod(resource, resourceId) });
}
requireInternalAuth(request) {
if (!this.hasInternalAuth(request)) throw httpError(401, "unauthorized");
}
hasInternalAuth(request) {
const authorization = String(request.headers.authorization || "");
const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
return secureEqual(token, this.internalToken);
}
enforceExchangeRate(request) {
const address = this.exchangeRateAddress(request);
const now = Date.now();
const attempts = (this.exchangeAttempts.get(address) || []).filter((time) => now - time < 60_000);
if (attempts.length >= 10) throw httpError(429, "too many pairing attempts");
attempts.push(now);
this.exchangeAttempts.set(address, attempts);
}
exchangeRateAddress(request) {
if (this.hasInternalAuth(request)) {
const forwardedAddress = singleHeaderValue(request, "x-aether-client-ip")?.trim();
if (forwardedAddress && net.isIP(forwardedAddress)) return forwardedAddress;
}
return request.socket.remoteAddress || "unknown";
}
cleanupExchangeAttempts() {
const now = Date.now();
for (const [address, attempts] of this.exchangeAttempts) {
const active = attempts.filter((time) => now - time < 60_000);
if (active.length) this.exchangeAttempts.set(address, active);
else this.exchangeAttempts.delete(address);
}
}
handleUpgrade(request, socket, head) {
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
if (requestUrl.pathname !== "/api/vscodex/ws" && requestUrl.pathname !== "/v1/connect") {
rejectUpgrade(socket, 404, "Not Found");
return;
}
const origin = request.headers.origin;
if (origin && this.allowedOrigins.size > 0 && !this.allowedOrigins.has(normalizeOrigin(origin))) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
this.wsServer.handleUpgrade(request, socket, head, (webSocket) => {
this.wsServer.emit("connection", webSocket, request);
this.handleWebSocket(webSocket, request);
});
}
handleWebSocket(socket, request) {
let hello = null;
let token = "";
let upstream = null;
let authenticating = false;
let room = null;
const queued = [];
const authTimer = setTimeout(() => socket.close(1008, "authentication required"), 10_000);
authTimer.unref();
const connectUpstream = async () => {
if (authenticating || upstream || !hello || !token) return;
authenticating = true;
let identity;
let upstreamToken;
if (hello.clientType === "host") {
const device = this.store.authenticate(token);
if (!device) throw httpError(401, "invalid device credential");
identity = { userId: device.user_id, deviceId: device.id };
room = await this.rooms.get(identity.userId, identity.deviceId);
upstreamToken = room.hostToken;
this.store.touch(device.id);
} else {
const ticket = this.credentials.consumeTicket(token);
if (!ticket || !this.store.get(ticket.user_id, ticket.device_id)) throw httpError(401, "invalid or expired browser ticket");
identity = { userId: ticket.user_id, deviceId: ticket.device_id };
room = await this.rooms.get(identity.userId, identity.deviceId);
upstreamToken = room.operatorToken;
}
this.rooms.retain(room);
upstream = new WebSocket(`${room.baseUrl}${hello.clientType === "host" ? "/v1/connect" : "/ws"}`, {
maxPayload: MAX_WS_BYTES,
});
upstream.once("open", () => {
if (socket.readyState !== WebSocket.OPEN) {
upstream.close();
return;
}
upstream.send(JSON.stringify(hello));
upstream.send(JSON.stringify(hello.clientType === "host"
? { v: 1, kind: "auth", accessToken: upstreamToken }
: { type: "auth", token: upstreamToken }));
for (const frame of queued.splice(0)) upstream.send(frame);
});
upstream.on("message", (data, isBinary) => {
if (socket.readyState === WebSocket.OPEN) socket.send(data, { binary: isBinary });
});
upstream.on("close", (code, reason) => {
if (socket.readyState === WebSocket.OPEN) socket.close(validCloseCode(code) ? code : 1011, reason.toString().slice(0, 120) || "relay closed");
});
upstream.on("error", () => {
if (socket.readyState === WebSocket.OPEN) socket.close(1011, "relay unavailable");
});
clearTimeout(authTimer);
};
socket.on("message", (data, isBinary) => {
if (isBinary) {
socket.close(1003, "JSON text frames only");
return;
}
if (upstream) {
const text = data.toString("utf8");
if (upstream.readyState === WebSocket.OPEN) upstream.send(text);
else queued.push(text);
return;
}
let message;
try {
message = JSON.parse(data.toString("utf8"));
} catch {
socket.close(1007, "invalid JSON");
return;
}
if (message?.kind === "hello") {
if (Number(message.protocol || 1) !== 1) {
socket.close(1002, "unsupported protocol");
return;
}
hello = {
v: 1,
kind: "hello",
clientType: message.clientType === "host" ? "host" : "web",
protocol: 1,
...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}),
...(Number.isFinite(Number(message.lastSeq)) ? { lastSeq: Number(message.lastSeq) } : {}),
};
} else if (message?.kind === "auth" || message?.type === "auth") {
token = typeof message.accessToken === "string" ? message.accessToken : typeof message.token === "string" ? message.token : "";
} else {
socket.close(1002, "hello and auth required");
return;
}
void connectUpstream().catch(() => socket.close(1008, "authentication failed"));
});
socket.on("close", () => {
clearTimeout(authTimer);
if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close();
if (room) this.rooms.release(room);
});
socket.on("error", () => {});
}
}
function parseDeviceToken(token) {
const match = /^avx1\.([0-9a-f-]{36})\.([A-Za-z0-9_-]{32,})$/.exec(String(token || ""));
return match ? { id: match[1], secret: match[2] } : null;
}
function deriveSecret(secret, salt) {
return crypto.scryptSync(secret, Buffer.from(salt, "base64url"), 32).toString("base64url");
}
function publicDevice(device, connected) {
return {
id: device.id,
name: device.name,
connected,
created_at: device.created_at,
last_seen_at: device.last_seen_at,
};
}
function normalizeName(value) {
const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, 80) : "";
return name || "VS Code";
}
function pairingCode() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = crypto.randomBytes(8);
let result = "";
for (let index = 0; index < 8; index += 1) result += alphabet[bytes[index] % alphabet.length];
return `${result.slice(0, 4)}-${result.slice(4)}`;
}
function normalizePairingCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z2-9]/g, "");
}
function randomToken() {
return crypto.randomBytes(32).toString("base64url");
}
function secureEqual(left, right) {
const a = Buffer.from(String(left || ""));
const b = Buffer.from(String(right || ""));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function singleHeaderValue(request, name) {
const distinctValues = request.headersDistinct?.[name];
if (Array.isArray(distinctValues)) return distinctValues.length === 1 ? distinctValues[0] : null;
const value = request.headers[name];
return typeof value === "string" ? value : null;
}
function parsePort(value, fallback) {
const parsed = Number(value ?? fallback);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("invalid port");
return parsed;
}
function normalizeOrigins(value) {
const values = Array.isArray(value) ? value : String(value || "").split(",");
return new Set(values.map(normalizeOrigin).filter(Boolean));
}
function normalizeOrigin(value) {
try {
return new URL(String(value).trim()).origin.toLowerCase();
} catch {
return "";
}
}
function validatePublicWsUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must be an absolute WebSocket URL");
}
if (url.protocol !== "wss:" && !(url.protocol === "ws:" && isLoopbackHost(url.hostname))) {
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must use wss:// outside loopback");
}
}
function isLoopbackHost(value) {
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
return host === "127.0.0.1" || host === "localhost" || host === "::1";
}
function validCloseCode(code) {
return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999);
}
function readJson(request) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
request.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_JSON_BYTES) {
reject(httpError(413, "request body too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
resolve(value);
} catch {
reject(httpError(400, "invalid JSON body"));
}
});
request.on("error", reject);
});
}
function jsonResponse(response, statusCode, body, extraHeaders = {}) {
if (response.headersSent) return;
const payload = Buffer.from(JSON.stringify(body));
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": payload.length,
"Cache-Control": "no-store",
...extraHeaders,
});
response.end(payload);
}
function rejectUpgrade(socket, status, reason) {
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function httpError(statusCode, message) {
return Object.assign(new Error(message), { statusCode, expose: statusCode < 500 });
}
function allowedMethod(resource, resourceId) {
if (resource === "devices" && resourceId) return "DELETE";
if (resource === "devices") return "GET";
return "POST";
}
async function main() {
const server = new AetherVscodexCloudServer();
const address = await server.start();
process.stdout.write(`Aether VS Codex sidecar listening on ${address.host}:${address.port}\n`);
const shutdown = async () => {
await server.stop();
process.exit(0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`${error.stack || error}\n`);
process.exitCode = 1;
});
}
module.exports = {
AetherVscodexCloudServer,
DeviceStore,
EphemeralCredentials,
RoomManager,
};
+37
View File
@@ -0,0 +1,37 @@
services:
app:
environment:
AETHER_VSCODEX_ENABLED: "true"
AETHER_VSCODEX_INTERNAL_URL: http://vscodex:8788
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
depends_on:
vscodex:
condition: service_healthy
volumes:
- ./aether-vscodex/web/dist:/opt/aether/releases/image/frontend/aether-vscodex:ro
vscodex:
build:
context: ./aether-vscodex
image: ${AETHER_VSCODEX_IMAGE:-aether-vscodex:local}
environment:
HOST: 0.0.0.0
PORT: 8788
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
AETHER_VSCODEX_ALLOWED_ORIGINS: ${AETHER_VSCODEX_ALLOWED_ORIGINS:?set AETHER_VSCODEX_ALLOWED_ORIGINS}
AETHER_VSCODEX_DATA_DIR: /var/lib/aether-vscodex
expose:
- "8788"
volumes:
- vscodex_data:/var/lib/aether-vscodex
logging:
driver: local
options:
max-size: "50m"
max-file: "3"
restart: unless-stopped
volumes:
vscodex_data:
+20
View File
@@ -0,0 +1,20 @@
# Cloud security model
## Trust boundaries
- Aether authenticates browser HTTP requests and resolves the user ID. The client never supplies a trusted user ID.
- The Node sidecar never receives an Aether access token or JWT signing key.
- A VS Code installation receives one revocable device credential. Only its scrypt hash is persisted.
- An iframe receives a random, one-time WebSocket ticket with a 60-second lifetime. Tickets are sent in an auth frame, never in a URL.
- The embedded UI is trusted, same-origin Aether code. `allow-same-origin` is required by the current integration, so the iframe is not a sandbox boundary for untrusted content even though the parent does not post its JWT into the frame.
- Relay state is isolated by `(user_id, device_id)`. A browser ticket and host credential must resolve to the same room.
## Network boundary
Run the sidecar on the private Compose network. Do not publish port 8788. Aether gateway is the only public HTTP and WebSocket entry point and authenticates internal API calls with `AETHER_VSCODEX_INTERNAL_TOKEN`.
`AETHER_VSCODEX_ALLOWED_ORIGINS` must contain the exact public Aether origin when the sidecar binds outside loopback. Public deployments must use HTTPS/WSS.
## Current scaling limit
The first release intentionally runs one sidecar replica. Pairing codes, browser tickets, and the live connection directory are process-local. Before adding replicas, move those records to a shared atomic store and add sticky or distributed WebSocket room routing.
@@ -0,0 +1,59 @@
"use strict";
const readline = require("node:readline");
let threadNumber = 0;
let turnNumber = 0;
let activeThread = null;
let activeTurn = null;
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
const input = readline.createInterface({ input: process.stdin });
input.on("line", (line) => {
let request;
try { request = JSON.parse(line); } catch { return; }
if (request.method === "initialize") {
send({ id: request.id, result: { userAgent: "fake", codexHome: "/tmp/codex" } });
send({ method: "remoteControl/status/changed", params: { status: "disabled" } });
return;
}
if (request.method === "thread/start") {
activeThread = `thread-${++threadNumber}`;
send({ id: request.id, result: { thread: { id: activeThread }, cwd: request.params?.cwd || "/tmp" } });
send({ method: "thread/started", params: { thread: { id: activeThread } } });
return;
}
if (request.method === "turn/start") {
activeTurn = `turn-${++turnNumber}`;
send({ id: request.id, result: { turn: { id: activeTurn } } });
send({ method: "turn/started", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
const text = request.params.input?.[0]?.text || "";
send({ method: "item/agentMessage/delta", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-1", delta: `echo: ${text}` } });
if (text.includes("approve")) {
send({ id: 9001, method: "item/commandExecution/requestApproval", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-2", command: "echo approval" } });
} else {
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
activeTurn = null;
}
return;
}
if (request.method === "turn/steer") {
send({ id: request.id, result: { turn: { id: activeTurn } } });
send({ method: "item/agentMessage/delta", params: { delta: `steered: ${request.params.input?.[0]?.text || ""}` } });
return;
}
if (request.method === "turn/interrupt") {
send({ id: request.id, result: {} });
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: request.params.turnId } } });
activeTurn = null;
return;
}
if (request.id === 9001 && (request.result || request.error)) {
send({ method: "item/agentMessage/delta", params: { delta: `approval response: ${JSON.stringify(request.result || request.error)}` } });
send({ method: "turn/completed", params: { threadId: activeThread, turn: { id: activeTurn } } });
activeTurn = null;
}
});
+39
View File
@@ -0,0 +1,39 @@
{
"name": "aether-vscodex",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aether-vscodex",
"version": "0.4.0",
"dependencies": {
"ws": "^8.18.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "aether-vscodex",
"version": "0.4.0",
"private": true,
"description": "Synchronous VS Code Codex mirroring and asynchronous Codex control for local Web and Aether",
"type": "commonjs",
"main": "relay/server.js",
"scripts": {
"start": "node relay/server.js",
"start:cloud": "node cloud/server.js",
"build:web": "npm --prefix web run build",
"build:extension": "npm --prefix vscode-extension run build",
"build": "npm run build:web && npm run build:extension",
"test": "node --test test/*.test.js",
"test:web": "npm --prefix web test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"ws": "^8.18.3"
}
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (!root || !root.document) return;
const bridge = api.createAetherEmbedBridge(root);
root.AetherVscodexEmbed = bridge;
if (bridge.active) bridge.start();
})(typeof window === "object" ? window : undefined, function () {
"use strict";
const VERSION = 1;
const PREFIX = "aether-vscodex/";
const INBOUND_TYPES = new Set(["connect", "context", "disconnect", "error"]);
function isAetherEmbed(locationLike) {
try {
return new URLSearchParams(locationLike?.search || "").get("embed") === "aether";
} catch {
return false;
}
}
function normalizeTheme(value) {
const theme = String(value || "").trim().toLowerCase();
return theme === "dark" || theme === "light" ? theme : "system";
}
function createAetherEmbedBridge(windowLike) {
const active = isAetherEmbed(windowLike.location);
const listeners = new Map();
const pending = new Map();
let started = false;
const emit = (name, payload) => {
for (const listener of listeners.get(name) || []) listener(payload);
};
const post = (type, payload = {}) => {
if (!active || windowLike.parent === windowLike) return false;
windowLike.parent.postMessage({ v: VERSION, type: `${PREFIX}${type}`, ...payload }, windowLike.location.origin);
return true;
};
const applyContext = (payload) => {
if (payload.locale && windowLike.VscodexI18n?.setLocale) {
windowLike.VscodexI18n.setLocale(payload.locale, { persist: false });
}
const theme = normalizeTheme(payload.theme);
const documentElement = windowLike.document?.documentElement;
if (documentElement) {
if (theme === "system") delete documentElement.dataset.theme;
else documentElement.dataset.theme = theme;
documentElement.style.colorScheme = theme === "system" ? "" : theme;
}
};
const handleMessage = (event) => {
if (!active || event.origin !== windowLike.location.origin || event.source !== windowLike.parent) return;
const message = event.data;
if (!message || typeof message !== "object" || message.v !== VERSION || typeof message.type !== "string") return;
if (!message.type.startsWith(PREFIX)) return;
const name = message.type.slice(PREFIX.length);
if (!INBOUND_TYPES.has(name)) return;
if (name === "connect" || name === "context") applyContext(message);
if (!(listeners.get(name)?.size)) pending.set(name, message);
emit(name, message);
};
return {
active,
version: VERSION,
start() {
if (!active || started) return;
started = true;
windowLike.document.body?.classList.add("embed-aether");
windowLike.addEventListener("message", handleMessage);
post("ready");
},
stop() {
if (!started) return;
started = false;
windowLike.removeEventListener("message", handleMessage);
listeners.clear();
pending.clear();
},
on(name, listener) {
if (!INBOUND_TYPES.has(name) || typeof listener !== "function") return () => undefined;
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
if (pending.has(name)) {
const message = pending.get(name);
pending.delete(name);
listener(message);
}
return () => listeners.get(name)?.delete(listener);
},
post,
requestTicket(payload = {}) {
return post("request-ticket", payload);
},
reportState(state, payload = {}) {
return post("state", { state, ...payload });
},
_handleMessage: handleMessage,
};
}
return { createAetherEmbedBridge, isAetherEmbed, normalizeTheme };
});
+541
View File
@@ -0,0 +1,541 @@
(function (root, factory) {
"use strict";
const api = factory(root);
if (typeof module === "object" && module.exports) module.exports = api;
if (root?.document) root.VscodexI18n = api;
})(typeof window === "object" ? window : undefined, function (root) {
"use strict";
const STORAGE_KEY = "aether-vscodex.locale";
const SUPPORTED = new Set(["zh-CN", "en-US"]);
const EN = Object.freeze({
"本地模式": "Local mode",
"独立模式": "Standalone mode",
"云端模式": "Cloud mode",
"控制模式": "Control mode",
"同步": "Sync",
"异步": "Async",
"同步模式跟随 VS Code 当前会话": "Sync mode follows the current VS Code conversation",
"异步模式可独立管理会话": "Async mode manages conversations independently",
"正在切换控制模式": "Switching control mode",
"控制模式已切换": "Control mode switched",
"控制模式切换失败": "Unable to switch control mode",
"当前任务或请求完成后才能切换控制模式": "The control mode can be changed after the current task or request finishes",
"同步模式下会话管理由 VS Code 控制": "VS Code controls conversation navigation in sync mode",
"当前模式不支持修改会话设置": "The current mode does not support changing conversation settings",
"本机连接(无需 token)": "Local connection (no token required)",
"本机模式无需填写;认证模式再填写": "No token is needed locally; enter one only for authenticated mode",
"访问 token(认证模式)": "Access token (authenticated mode)",
"粘贴 relay 启动时打印的 token": "Paste the token printed when the relay started",
"本地连接无需 token": "No token is needed for a local connection",
"编辑外部文件和联网时始终询问": "Always ask before editing external files or using the network",
"不限制联网或文件访问": "Allow unrestricted network and file access",
"查看请求数据": "View request data",
"查看上下文用量": "View context usage",
"创建新会话": "New conversation",
"打开会话历史": "Open conversation history",
"待处理的 Codex 请求": "Pending Codex requests",
"当前会话": "Current conversation",
"当前模型": "Current model",
"切换模型": "Change model",
"等待 VS Code 主机": "Waiting for VS Code host",
"等待连接": "Waiting for connection",
"对话内容": "Conversation",
"发送 JSON": "Send JSON",
"发送后续指令": "Send follow-up",
"发送消息": "Send message",
"返回会话列表": "Back to conversations",
"返回模型强度": "Back to model effort",
"高级": "Advanced",
"简洁": "Simple",
"更多操作": "More actions",
"更高效": "More efficient",
"更智能": "More capable",
"工作目录": "Working directory",
"工作区": "Workspace",
"工作区写入": "Workspace write",
"回到最新消息": "Jump to latest message",
"正在工作,回到最新消息": "Working, jump to latest message",
"会话历史": "Conversation history",
"会话设置": "Conversation settings",
"仅本次 turn": "This turn only",
"仅查看文件,不修改工作区": "View files without changing the workspace",
"仅对可能不安全的操作询问": "Ask only for potentially unsafe actions",
"拒绝": "Deny",
"可用会话": "Available conversations",
"连接设置": "Connection settings",
"留空使用默认模型": "Leave empty to use the default model",
"模式": "Mode",
"模型": "Model",
"模型与推理强度": "Model and reasoning effort",
"默认": "Default",
"启动新 thread": "Start new thread",
"强度": "Effort",
"切换模型与推理强度": "Change model and reasoning effort",
"清除搜索": "Clear search",
"清空当前输出": "Clear current output",
"清空对话": "Clear conversation",
"取消": "Cancel",
"权限设置": "Permission settings",
"确认": "Confirm",
"确认完全访问": "Confirm full access",
"沙箱": "Sandbox",
"上下文用量": "Context usage",
"设置": "Settings",
"审批策略": "Approval policy",
"使用 config.toml 中的权限": "Use permissions from config.toml",
"使用左右方向键调整强度": "Use the left and right arrow keys to adjust effort",
"授权范围": "Authorization scope",
"授权与输入": "Approvals and input",
"刷新会话列表": "Refresh conversations",
"搜索最近会话": "Search recent conversations",
"提交后续变更要求": "Ask for follow-up changes",
"添加工作区上下文": "Add workspace context",
"添加文件": "Add files",
"添加文件及更多内容": "Add files and more",
"添加照片": "Add photos",
"推理强度": "Reasoning effort",
"完全访问": "Full access",
"完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。": "Full access lets Codex run commands, use the internet, and edit files outside the workspace.",
"网页搜索": "Web search",
"未认证": "Unauthenticated",
"显示 Codex": "Show Codex",
"修改权限": "Change permissions",
"需要时询问": "Ask when needed",
"已附着当前会话": "Attached to current conversation",
"隐藏面板": "Hide panel",
"由 Codex 审批": "Let Codex decide",
"允许": "Allow",
"允许一次": "Allow once",
"暂无待处理请求": "No pending requests",
"暂无用量数据": "No usage data",
"展开面板": "Expand panel",
"正在连接": "Connecting",
"只读": "Read only",
"中断当前 turn": "Interrupt current turn",
"重新同步": "Resync",
"子代理": "Subagent",
"自定义": "Custom",
"最近会话": "Recent conversations",
"Codex 消息": "Codex messages",
"JSON 响应": "JSON response",
"语言": "Language",
"中文": "Chinese",
"跟随浏览器": "Use browser language",
"正在连接云端会话": "Connecting to cloud conversation",
"正在等待云端连接": "Waiting for cloud connection",
"云端连接已断开": "Cloud connection disconnected",
"云端连接配置无效": "Invalid cloud connection configuration",
"云端连接地址必须与当前页面同源": "The cloud connection URL must be same-origin",
"正在获取新的连接凭证": "Requesting new connection credentials",
"父页面已断开连接": "Disconnected by the parent page",
"当前 relay 需要 token": "This relay requires a token",
"WebSocket 未连接": "WebSocket is not connected",
"连接中": "Connecting",
"同步中": "Syncing",
"已连接": "Connected",
"认证失败,准备重连": "Authentication failed; preparing to reconnect",
"准备重连": "Preparing to reconnect",
"重连中": "Reconnecting",
"收到无法解析的 relay 消息": "Received an unreadable relay message",
"等待 relay 连接": "Waiting for relay connection",
"等待 VS Code 主机连接": "Waiting for VS Code host",
"VS Code 主机未连接": "VS Code host is disconnected",
"等待 VS Code 伴随扩展连接": "Waiting for the VS Code companion extension",
"VS Code 伴随扩展未连接": "VS Code companion extension is disconnected",
"等待在 VS Code 中打开 Codex 会话": "Open a Codex conversation in VS Code to continue",
"会话已关闭": "Conversation closed",
"VS Code 会话已关闭": "VS Code conversation closed",
"会话操作失败": "Conversation operation failed",
"当前任务结束或请求处理后才能切换": "You can switch after the current task or request finishes",
"目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它": "The target conversation did not return a VS Code snapshot. Open it in the official Codex panel first.",
"当前 relay 版本不支持此会话操作,请重启 relay": "This relay version does not support the conversation action. Restart the relay.",
"正在读取会话…": "Loading conversations...",
"正在切换会话…": "Switching conversation...",
"无法读取会话": "Unable to load conversations",
"没有匹配的会话": "No matching conversations",
"没有可附加的会话": "No attachable conversations",
"没有可控制的会话": "No controllable conversations",
"正在切换": "Switching",
"未打开": "Not open",
"当前": "Current",
"可切换": "Available",
"会话": "Conversation",
"当前角色不能创建会话": "Your current role cannot create conversations",
"正在创建新会话": "Creating a new conversation",
"无法创建新会话": "Unable to create a new conversation",
"当前任务仍在运行或等待授权,暂不能切换": "The current task is running or awaiting approval, so it cannot be switched yet",
"会话切换失败": "Conversation switch failed",
"正在确认会话": "Confirming conversation",
"正在加载会话": "Loading conversation",
"会话已切换": "Conversation switched",
"正在更新模型设置": "Updating model settings",
"模型设置已更新": "Model settings updated",
"无法更新模型设置": "Unable to update model settings",
"已停止": "Stopped",
"成功": "Succeeded",
"无输出": "No output",
"等待输出…": "Waiting for output...",
"执行步骤": "Action",
"正在读取文件": "Reading files",
"读取完成": "Finished reading",
"已读取文件运行了命令": "Read files and ran a command",
"已读取文件": "Read files",
"编辑了文件": "Edited files",
"已完成计划": "Completed plan",
"读取文件失败": "Failed to read files",
"已停止读取文件": "Stopped reading files",
"读取文件": "Read files",
"已运行命令": "Ran command",
"正在运行命令": "Running command",
"正在思考": "Thinking",
"正在制定计划": "Creating a plan",
"正在编辑文件": "Editing files",
"正在处理": "Working",
"已完成思考": "Finished thinking",
"计划完成": "Plan completed",
"文件编辑完成": "Finished editing files",
"工作说明": "Progress update",
"计划": "Plan",
"文件变更": "File changes",
"等待授权": "Waiting for approval",
"正在生成": "Generating",
"已中断": "Interrupted",
"失败": "Failed",
"已完成": "Completed",
"正在工作": "Working",
"正在等待你的回答": "Waiting for your answer",
"正在搜索网页": "Searching the web",
"执行失败": "Action failed",
"处理中": "Working",
"思考": "Reasoning",
"编辑文件": "Edit files",
"思考中": "Thinking",
"编辑中": "Editing",
"进行中": "In progress",
"异常": "Error",
"未读": "Unread",
"本地会话": "Local conversation",
"默认拒绝,请明确允许": "Denied by default; allow explicitly",
"需要远程确认或输入": "Remote confirmation or input is required",
"允许运行命令?": "Allow this command?",
"允许修改文件?": "Allow file changes?",
"需要扩大权限": "Additional permissions required",
"Codex 需要你的回答": "Codex needs your answer",
"需要外部服务确认": "External service confirmation required",
"Codex 请求确认": "Codex requests confirmation",
"高风险": "High risk",
"低风险": "Low risk",
"需确认": "Confirmation required",
"请输入": "Enter a response",
"提交回答": "Submit answer",
"发送自定义响应": "Send custom response",
"自定义响应不是有效 JSON": "The custom response is not valid JSON",
"响应不是有效 JSON": "The response is not valid JSON",
"远程参与者拒绝": "Denied by remote participant",
"状态": "Status",
"命令": "Command",
"详情": "Details",
"复制消息": "Copy message",
"复制命令": "Copy command",
"复制输出": "Copy output",
"未知": "Unknown",
"未知错误": "Unknown error",
"已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。": "Attached to the current VS Code Codex conversation. Messages, output, and approvals all return to that conversation.",
"当前为独立 app-server 模式。": "Currently using standalone app-server mode.",
"已附着现有会话": "Attached to existing conversation",
"通用 Codex 模型": "General-purpose Codex model",
"平衡速度与推理": "Balanced speed and reasoning",
"可用模型": "Available model",
"极低": "Minimal",
"轻度": "Low",
"标准": "Medium",
"深度": "High",
"极高": "Extra high",
"最大": "Maximum",
"此模型使用默认推理强度": "This model uses its default reasoning effort",
"返回简洁模型选择": "Return to simple model selection",
"显示高级模型选项": "Show advanced model options",
"自定义权限由 config.toml 管理": "Custom permissions are managed by config.toml",
"正在等待指示": "Waiting for instructions",
"正在工作": "Working",
"命令输出": "Command output",
"工具输出": "Tool output",
"发送 Steer": "Send steer",
"会话切换失败,已恢复原会话": "Conversation switch failed; restored the previous conversation",
"(空消息)": "(empty message)",
"今天": "Today",
"昨天": "Yesterday",
"未完成": "Not completed",
"步骤": "Step",
"查看图像": "View image",
"等待输入": "Waiting for input",
"读取文件运行命令失败": "Failed to read files and run a command",
"发送输入": "Send input",
"工具": "Tool",
"工具失败": "Tool failed",
"正在搜索": "Searching",
"你停止了工作": "You stopped working",
"关闭子代理": "Close subagent",
"恢复子代理": "Resume subagent",
"启动子代理": "Start subagent",
"搜索": "Search",
"文件": "File",
"新会话已在 VS Code 中打开": "The new conversation opened in VS Code",
"事件窗口已过期,请以当前快照为准": "The event window expired; the current snapshot is authoritative",
"执行状态未知,请等待主机恢复": "Execution status is unknown; wait for the host to recover",
"文件已截断": "File truncated",
"已拒绝": "Denied",
"已开始工作": "Started working",
"已添加工作区上下文": "Added workspace context",
"已添加网页搜索": "Added web search",
"运行命令": "Run command",
"整理上下文": "Compacting context",
"正在切换会话": "Switching conversation",
"MCP 工具": "MCP tool",
" · @ 可标记代理": " · @ to mention agents",
});
const EN_PATTERNS = Object.freeze([
[/^用时 1分钟(\d+)秒$/, "Worked for 1m{1}s"],
[/^用时 (\d+)分(\d+)秒$/, "Worked for {1}m{2}s"],
[/^用时 1分钟$/, "Worked for 1m"],
[/^用时 (\d+)分$/, "Worked for {1}m"],
[/^用时 (\d+)秒$/, "Worked for {1}s"],
[/^用时 (\d+)毫秒$/, "Worked for {1}ms"],
[/^用时\s+(.+)$/, "Worked for {1}"],
[/^已思考 1分钟(\d+)秒$/, "Thought for 1m{1}s"],
[/^已思考 (\d+)分(\d+)秒$/, "Thought for {1}m{2}s"],
[/^已思考 (\d+)秒$/, "Thought for {1}s"],
[/^已思考\s+(.+)$/, "Thought for {1}"],
[/^退出码\s+(.+)$/, "Exit code {1}"],
[/^正在读取\s+(.+)$/, "Reading {1}", [1]],
[/^已读取\s+(.+)$/, "Read {1}", [1]],
[/^读取失败\s*·\s*(.+)$/, "Failed to read {1}", [1]],
[/^已停止读取\s+(.+)$/, "Stopped reading {1}", [1]],
[/^读取\s+(.+)$/, "Read {1}", [1]],
[/^已读取这些内容\s*·\s*(\d+)\s*个文件(.*)$/, "Read these items · {1} files{2}"],
[/^已在\s+(.+)\s+内运行\s+(.+)$/, "Ran {2} in {1}", [2]],
[/^命令运行失败\s*·\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Command failed · {1} · {2}", [1]],
[/^命令运行失败\s*·\s*(.+)$/, "Command failed · {1}", [1]],
// Renderer-owned disclosure labels. Keep the captured command/model text
// intact; only the surrounding UI words are localized.
[/^命令\s*·\s*(.+)$/, "Command · {1}", [1]],
[/^已工具\s*·\s*(.+)$/, "Tool completed · {1}"],
[/^当前模型\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大),切换模型$/, "Current model: {1} {2}. Change model", [1]],
[/^已停止\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Stopped {1} · {2}", [1]],
[/^已运行\s*(.+)$/, "Ran {1}", [1]],
[/^命令运行失败\s*(.*)$/, "Command failed{1}", [1]],
[/^命令:\s*(.+?)(执行状态未知,请等待主机恢复)$/, "Command: {1} (execution status unknown; wait for the host to recover)", [1]],
[/^命令:\s*(.+)$/, "Command: {1}", [1]],
[/^已停止\s*(.+)$/, "Stopped {1}", [1]],
[/^正在运行\s+(.+)$/, "Running {1}", [1]],
[/^(.+?)\s*·\s*失败$/, "{1} · Failed"],
[/^(.+?)\s*·\s*已中断$/, "{1} · Interrupted"],
[/^(.+)\s+失败$/, "{1} failed"],
[/^编辑了文件\s*·\s*(.+)$/, "Edited files · {1}"],
[/^已完成计划\s*·\s*(.+)$/, "Completed plan · {1}"],
[/^(\d+)\/(\d+)\s*个会话$/, "{1}/{2} conversations"],
[/^(\d+)\s*个会话$/, "{1} conversations"],
[/^会话\s+(.+)$/, "Conversation {1}", [1]],
[/^工作区\s*·\s*(.+)$/, "Workspace · {1}", [1]],
[/^昨天\s+(.+)$/, "Yesterday {1}", [1]],
[/^正在切换到「(.+)」…?$/, "Switching to “{1}”...", [1]],
[/^你在\s+(.+)\s+后停止了$/, "You stopped after {1}"],
[/^执行失败\s*·\s*(.+)$/, "Action failed · {1}"],
[/^新会话创建失败:(.+)$/, "Unable to create a new conversation: {1}", [1]],
[/^模型设置更新失败:(.+)$/, "Unable to update model settings: {1}", [1]],
[/^(.+)(执行状态未知,请等待主机恢复)$/, "{1} (execution status unknown; wait for the host to recover)", [1]],
[/^(.+) 完成$/, "{1} completed", [1]],
[/^请求 #(.+) 已提交$/, "Request #{1} submitted"],
[/^请求 #(.+) 已发送,等待 VS Code 主机确认$/, "Request #{1} sent; waiting for the VS Code host"],
[/^无法读取 (.+)$/, "Unable to read {1}", [1]],
[/^\[图片附件:(.+)\]$/, "[Image attachment: {1}]", [1]],
[/^(.+) 已开始工作$/, "{1} started working", [1]],
[/^(.+) 已完成$/, "{1} completed", [1]],
[/^(.+) 已中断$/, "{1} interrupted", [1]],
[/^…(文件已截断)$/, "... (file truncated)"],
[/^当前模型\s+(.+),切换模型$/, "Current model: {1}. Change model", [1]],
[/^切换模型(当前\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大))$/, "Change model (current: {1} {2})", [1]],
[/^切换模型(当前\s+(.+))$/, "Change model (current: {1})", [1]],
[/^修改权限,当前为(.+)$/, "Change permissions. Current: {1}"],
[/^修改权限(当前:(.+))$/, "Change permissions (current: {1})"],
[/^上下文已使用\s*(\d+)%(剩余\s*(\d+)%)$/, "Context used: {1}% ({2}% remaining)"],
[/^(\d+)%\s*已使用$/, "{1}% used"],
[/^剩余\s+(.+)\s+tokens$/, "{1} tokens remaining"],
[/^当前上下文\s+(.+)\s+tokens$/, "Current context: {1} tokens"],
[/^最近请求\s+(.+)\s+tokens$/, "Latest request: {1} tokens"],
[/^累计\s+(.+)\s+tokens$/, "Total: {1} tokens"],
[/^使用\s+(.+)$/, "Using {1}", [1]],
[/^已用时\s+(.+)$/, "Elapsed: {1}"],
[/^(\d+)\s*个后台代理(.*)$/, "{1} background agents{2}"],
[/^(\d+)毫秒$/, "{1}ms"],
[/^(\d+)秒$/, "{1}s"],
[/^1分钟(\d+)秒$/, "1m{1}s"],
[/^(\d+)分(\d+)秒$/, "{1}m{2}s"],
[/^1分钟(\d+秒)?$/, "1m{1}"],
[/^(\d+)分(\d+秒)?$/, "{1}m{2}"],
]);
const ZH = Object.freeze(Object.fromEntries(Object.entries(EN).map(([source, translated]) => [translated, source])));
let currentLocale = "zh-CN";
let observer = null;
const textSources = new WeakMap();
const textRendered = new WeakMap();
const attributeSources = new WeakMap();
const attributeRendered = new WeakMap();
function normalizeLocale(value) {
const locale = String(value || "").trim().replace("_", "-").toLowerCase();
return locale.startsWith("zh") ? "zh-CN" : "en-US";
}
function embeddedMode() {
if (root?.AetherVscodexEmbed?.active) return true;
try { return new URLSearchParams(root?.location?.search || "").get("embed") === "aether"; }
catch { return false; }
}
function interpolate(template, values) {
return String(template).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? "");
}
function translate(value, locale = currentLocale, depth = 0) {
const source = String(value ?? "");
if (!source) return source;
if (normalizeLocale(locale) === "zh-CN") return ZH[source] || source;
if (Object.prototype.hasOwnProperty.call(EN, source)) return EN[source];
for (const [pattern, template, rawIndexes] of EN_PATTERNS) {
const match = source.match(pattern);
if (match) {
const translatedMatch = match.map((part, index) => index === 0
? part
: rawIndexes?.includes(index) ? part
: depth < 6 ? translate(part, locale, depth + 1) : (EN[part] || part));
return interpolate(template, translatedMatch);
}
}
return source;
}
function shouldSkipTextNode(node) {
const parent = node?.parentElement;
return Boolean(parent?.closest?.("code, pre, .message-body, .request-summary, .request-questions, .request-json, .request-command, .diff-output, .terminal-output, .session-option-title, .subagent-name, .subagent-summary-label"));
}
function translateTextNode(node) {
if (!node || shouldSkipTextNode(node)) return;
const current = node.nodeValue;
const previousRendered = textRendered.get(node);
if (!textSources.has(node) || current !== previousRendered) textSources.set(node, current);
const source = textSources.get(node);
const leading = source.match(/^\s*/)?.[0] || "";
const trailing = source.match(/\s*$/)?.[0] || "";
const core = source.slice(leading.length, source.length - trailing.length);
if (!core) return;
const translated = translate(core);
const rendered = `${leading}${translated}${trailing}`;
textRendered.set(node, rendered);
if (rendered !== current) node.nodeValue = rendered;
}
function translateAttributes(element) {
if (!element?.getAttribute || element.closest?.(".message-body, pre, code")) return;
let sources = attributeSources.get(element);
let renderedValues = attributeRendered.get(element);
if (!sources) { sources = new Map(); attributeSources.set(element, sources); }
if (!renderedValues) { renderedValues = new Map(); attributeRendered.set(element, renderedValues); }
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
if (!element.hasAttribute(attribute)) continue;
const current = element.getAttribute(attribute);
if (!sources.has(attribute) || current !== renderedValues.get(attribute)) sources.set(attribute, current);
const source = sources.get(attribute);
const translated = translate(source);
renderedValues.set(attribute, translated);
if (translated !== current) element.setAttribute(attribute, translated);
}
}
function translateTree(node) {
if (!root?.document || !node) return;
if (node.nodeType === 3) {
translateTextNode(node);
return;
}
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
if (node.nodeType === 1) translateAttributes(node);
const walker = root.document.createTreeWalker(node, root.NodeFilter.SHOW_ELEMENT | root.NodeFilter.SHOW_TEXT);
for (let current = walker.nextNode(); current; current = walker.nextNode()) {
if (current.nodeType === 3) translateTextNode(current);
else translateAttributes(current);
}
}
function applyDocument() {
if (!root?.document) return;
root.document.documentElement.lang = currentLocale;
translateTree(root.document.body);
const selector = root.document.getElementById("localeSelect");
if (selector && selector.value !== currentLocale) selector.value = currentLocale;
}
function setLocale(value, options = {}) {
currentLocale = SUPPORTED.has(value) ? value : normalizeLocale(value);
if (options.persist !== false && root?.localStorage && !embeddedMode()) {
try { root.localStorage.setItem(STORAGE_KEY, currentLocale); } catch { /* storage may be disabled */ }
}
applyDocument();
if (root?.CustomEvent) root.dispatchEvent?.(new root.CustomEvent("aether-vscodex:locale", { detail: { locale: currentLocale } }));
return currentLocale;
}
function initialLocale() {
if (embeddedMode()) return normalizeLocale(root?.navigator?.language);
try {
const saved = root?.localStorage?.getItem(STORAGE_KEY);
if (SUPPORTED.has(saved)) return saved;
} catch { /* storage may be disabled */ }
return normalizeLocale(root?.navigator?.language);
}
function start() {
if (!root?.document) return;
currentLocale = initialLocale();
applyDocument();
if (typeof root.MutationObserver === "function" && !observer) {
observer = new root.MutationObserver((records) => {
if (currentLocale === "zh-CN") return;
for (const record of records) {
if (record.type === "characterData") translateTextNode(record.target);
else if (record.type === "attributes") translateAttributes(record.target);
else for (const node of record.addedNodes) translateTree(node);
}
});
observer.observe(root.document.documentElement, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["title", "aria-label", "placeholder", "data-placeholder"],
});
}
}
const api = {
locale: () => currentLocale,
normalizeLocale,
setLocale,
start,
t: (value) => translate(value),
translate,
translateTree,
messages: { "zh-CN": Object.freeze({}), "en-US": EN },
};
if (root?.document) {
if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", start, { once: true });
else start();
}
return api;
});
+303
View File
@@ -0,0 +1,303 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Codex</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body class="codex-app local-no-auth">
<div class="codex-panel">
<div class="connection" aria-live="polite" hidden>
<span id="connectionDot" class="dot offline"></span>
<span id="connectionText">正在连接</span>
<span id="roleBadge" class="badge">未认证</span>
</div>
<main class="chat-shell">
<section class="chat-header" aria-label="当前会话">
<div class="thread-heading">
<button id="backButton" class="icon-button header-back-button" type="button" data-panel-action="back" title="返回会话列表" aria-label="返回会话列表" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.75 3.5 5.25 8l4.5 4.5M5.5 8h6.25" /></svg>
</button>
<button id="sessionPickerButton" class="thread-picker-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="打开会话历史" aria-label="打开会话历史" disabled>
<h2 id="threadTitle">Codex</h2>
</button>
<span id="appState" class="status-text" aria-live="polite">等待 VS Code 主机</span>
</div>
<div class="thread-actions">
<button class="icon-button" type="button" data-panel-action="menu" title="更多操作" aria-label="更多操作">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3" cy="8" r="1" /><circle cx="8" cy="8" r="1" /><circle cx="13" cy="8" r="1" /></svg>
</button>
<button id="historyButton" class="icon-button header-history-button" type="button" data-panel-action="history" title="会话历史" aria-label="会话历史" hidden>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></svg>
</button>
<button class="icon-button" type="button" data-panel-action="settings" title="设置" aria-label="设置">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.7 2h2.6l.4 1.6c.4.2.8.4 1.2.7l1.6-.6 1.3 2.2-1.2 1.1a5 5 0 0 1 0 1.4l1.2 1.1-1.3 2.2-1.6-.6c-.4.3-.8.5-1.2.7L9.3 14H6.7l-.4-1.6a5 5 0 0 1-1.2-.7l-1.6.6-1.3-2.2 1.2-1.1a5 5 0 0 1 0-1.4L2.2 6l1.3-2.2 1.6.6c.4-.3.8-.5 1.2-.7L6.7 2Z" /><circle cx="8" cy="8" r="1.7" /></svg>
</button>
<button id="newSessionButton" class="icon-button new-session-button" type="button" data-panel-action="new-session" title="创建新会话" aria-label="创建新会话" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.25 3.25h5.5a1.5 1.5 0 0 1 1.5 1.5v2.5" /><path d="M3.25 3.25v9.5h6" /><path d="m8.2 11.35 4.55-4.55 1.25 1.25-4.55 4.55-2 .5Z" /></svg>
</button>
</div>
</section>
<div id="panelMenu" class="panel-popover panel-menu" hidden>
<button type="button" data-menu-action="sessions" hidden>最近会话</button>
<button type="button" data-menu-action="clear">清空当前输出</button>
<button type="button" data-menu-action="refresh">重新同步</button>
<button type="button" data-menu-action="expand">展开面板</button>
<button type="button" data-menu-action="close">隐藏面板</button>
</div>
<div id="detailsPopover" class="panel-popover details-popover settings-popover" hidden role="dialog" aria-label="设置">
<div class="popover-title">设置</div>
<div class="settings-shortcuts">
<button type="button" data-settings-action="model"><span>模型与推理强度</span><span id="settingsModelValue">默认</span></button>
<button type="button" data-settings-action="permission"><span>修改权限</span><span id="settingsPermissionValue">工作区写入</span></button>
<label id="localeSetting" class="settings-locale">
<span>语言</span>
<select id="localeSelect" aria-label="语言">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
</label>
</div>
<div class="settings-divider"></div>
<div class="popover-subtitle">当前会话</div>
<dl>
<dt>工作区</dt><dd id="popoverCwd">-</dd>
<dt>模式</dt><dd id="popoverMode">本地模式</dd>
<dt>thread</dt><dd id="popoverThread">-</dd>
</dl>
</div>
<div id="sessionPicker" class="panel-popover session-picker" hidden role="dialog" aria-label="最近会话">
<div class="session-picker-header">
<span class="popover-title">最近会话</span>
<button id="sessionPickerRefresh" class="session-picker-refresh" type="button" title="刷新会话列表" aria-label="刷新会话列表">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5V2m0 0h-3m3 0-2.1 2.1A5 5 0 1 0 13 9" /></svg>
</button>
</div>
<div class="session-search">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.8" cy="6.8" r="3.8" /><path d="m9.7 9.7 3.2 3.2" /></svg>
<label class="sr-only" for="sessionSearchInput">搜索最近会话</label>
<input id="sessionSearchInput" type="search" autocomplete="off" spellcheck="false" placeholder="搜索最近会话" aria-label="搜索最近会话" aria-controls="sessionList" aria-expanded="false" />
<button id="sessionSearchClear" class="session-search-clear" type="button" title="清除搜索" aria-label="清除搜索" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" /></svg>
</button>
</div>
<div id="sessionPickerStatus" class="session-picker-status" role="status" aria-live="polite"></div>
<div id="sessionList" class="session-list" role="listbox" aria-label="可用会话" tabindex="0"></div>
</div>
<section class="chat-panel" aria-label="对话内容">
<div id="output" class="output chat-scroll" tabindex="0" aria-live="polite" aria-label="Codex 消息"></div>
<button id="scrollToBottom" class="scroll-to-bottom" type="button" aria-label="回到最新消息" aria-hidden="true" tabindex="-1">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v9M4.5 8.5 8 12l3.5-3.5" /></svg>
<span class="scroll-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div id="inlineRequests" class="inline-requests" aria-live="polite" aria-label="待处理的 Codex 请求"></div>
</section>
<section id="messageForm" class="composer" aria-label="发送消息">
<section id="subagentsPanel" class="subagents-panel" aria-label="子代理" hidden>
<button id="subagentsToggle" class="subagents-toggle" type="button" aria-expanded="false">
<span class="subagents-title">子代理</span>
<span id="subagentsCount" class="subagents-count"></span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
</button>
<div id="subagentsList" class="subagents-list"></div>
</section>
<div id="liveActivity" class="live-activity" role="status" aria-live="polite" hidden>
<span class="activity-spinner" aria-hidden="true"></span>
<span class="activity-label"></span>
<span class="activity-dots" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="activity-elapsed"></span>
</div>
<div class="composer-surface">
<div
id="messageInput"
class="composer-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
data-placeholder="提交后续变更要求"
spellcheck="true"
></div>
<div class="composer-footer">
<div class="composer-hint">
<button id="composerPlusButton" class="composer-icon-button" type="button" aria-haspopup="menu" aria-expanded="false" title="添加文件及更多内容" aria-label="添加文件及更多内容">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
</button>
<div id="composerPlusMenu" class="composer-popover composer-plus-menu" role="menu" hidden>
<div class="composer-popover-heading">添加文件及更多内容</div>
<button type="button" role="menuitem" data-composer-action="attach">添加文件</button>
<button type="button" role="menuitem" data-composer-action="photo">添加照片</button>
<button type="button" role="menuitem" data-composer-action="workspace">添加工作区上下文</button>
<button type="button" role="menuitem" data-composer-action="web-search">网页搜索</button>
</div>
<input id="attachmentInput" type="file" accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h,image/*" multiple hidden />
<button id="permissionChip" class="permission-chip" type="button" aria-haspopup="menu" aria-expanded="false" title="修改权限" aria-label="修改权限">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 13 4v3.6c0 3-2 5.6-5 6.6-3-1-5-3.6-5-6.6V4l5-2.2Z" /><path d="m5.5 8 1.6 1.6L10.8 6" /></svg>
<span id="permissionLabel">工作区写入</span>
<svg class="permission-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
</button>
<div id="permissionMenu" class="composer-popover permission-menu" role="menu" aria-label="权限设置" hidden>
<div class="composer-popover-heading">修改权限</div>
<button type="button" role="menuitemradio" data-permission-mode="ask" aria-checked="false"><span>需要时询问</span><small>编辑外部文件和联网时始终询问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="auto" aria-checked="false"><span>由 Codex 审批</span><small>仅对可能不安全的操作询问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="full" aria-checked="false"><span>完全访问</span><small>不限制联网或文件访问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="custom" aria-checked="false"><span>自定义</span><small>使用 config.toml 中的权限</small></button>
<button type="button" role="menuitemradio" data-permission-mode="readonly" aria-checked="false"><span>只读</span><small>仅查看文件,不修改工作区</small></button>
</div>
<div id="permissionConfirm" class="permission-confirm" role="dialog" aria-modal="true" aria-labelledby="permissionConfirmTitle" hidden>
<div id="permissionConfirmTitle" class="permission-confirm-title">确认完全访问</div>
<p>完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。</p>
<div class="permission-confirm-actions">
<button id="permissionConfirmCancel" type="button">取消</button>
<button id="permissionConfirmAccept" class="primary" type="button">确认</button>
</div>
</div>
<div id="usagePicker" class="usage-picker" hidden>
<button id="usageButton" class="usage-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="查看上下文用量" aria-label="查看上下文用量"><span id="usageRing" class="usage-ring" aria-hidden="true"><span id="usageLabel">0%</span></span></button>
<div id="usageMenu" class="composer-popover usage-menu" role="dialog" aria-label="上下文用量" hidden>
<div class="composer-popover-heading">上下文用量</div>
<div id="usageSummary" class="usage-summary">暂无用量数据</div>
<div class="usage-meter"><span id="usageMeterBar"></span></div>
<div id="usageDetails" class="usage-details"></div>
</div>
</div>
<span id="factApp" class="sr-only">-</span>
<span id="factClients" class="sr-only">-</span>
<span id="factRequests" class="sr-only">0</span>
</div>
<div class="composer-actions">
<div id="modelPicker" class="model-picker">
<button id="modelPickerButton" class="model-picker-button" type="button" aria-haspopup="menu" aria-expanded="false" title="切换模型与推理强度" hidden>
<span id="modelLabel" class="model-label"></span>
<span id="modelEffortLabel" class="model-effort-label"></span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
</button>
<div id="modelMenu" class="model-menu" role="menu" aria-label="模型与推理强度" hidden>
<div id="modelPowerView" class="model-power-view">
<div class="model-power-heading">
<span>推理强度</span>
<button id="modelAdvancedToggle" class="model-advanced-toggle" type="button">高级</button>
</div>
<div class="model-power-control">
<span class="model-power-label">更高效</span>
<input id="modelPowerSlider" class="model-power-slider" type="range" min="0" max="3" step="1" value="1" aria-label="强度" aria-describedby="modelPowerInstructions" />
<span class="model-power-label">更智能</span>
</div>
<div id="modelPowerValue" class="model-power-value"></div>
<span id="modelPowerInstructions" class="sr-only">使用左右方向键调整强度</span>
</div>
<div id="modelAdvancedView" class="model-advanced-view" hidden>
<div class="model-advanced-toolbar">
<button id="modelAdvancedBack" class="model-advanced-back" type="button" aria-label="返回模型强度">‹</button>
<span>模型与推理强度</span>
</div>
<div class="model-menu-heading">模型</div>
<div id="modelOptions" class="model-options" role="listbox" aria-label="模型"></div>
<div class="model-menu-heading effort-heading">推理强度</div>
<div id="effortOptions" class="effort-options" role="listbox" aria-label="推理强度"></div>
</div>
</div>
</div>
<button id="interruptButton" class="compact-action interrupt-action" type="button" disabled title="中断当前 turn" aria-label="中断当前 turn">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1" /></svg>
</button>
<button id="steerButton" class="primary compact-action steer-action" type="button" disabled title="发送后续指令" aria-label="发送后续指令">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
</button>
<button id="startTurnButton" class="primary send-button" type="button" disabled title="发送消息" aria-label="发送消息">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
</button>
</div>
</div>
</div>
<div class="mode-row">
<span class="connection-mode-label">
<svg class="mode-icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M5 13h6M8 11v2" /></svg>
<span id="modeLabel">本地模式</span>
</span>
<div id="controlModeSwitch" class="control-mode-switch" role="group" aria-label="控制模式" aria-busy="false" data-mode="sync" data-switching="false">
<button type="button" data-control-mode="sync" aria-pressed="true" title="同步模式跟随 VS Code 当前会话" disabled>同步</button>
<button type="button" data-control-mode="async" aria-pressed="false" title="异步模式可独立管理会话" disabled>异步</button>
</div>
</div>
</section>
</main>
</div>
<button id="restorePanel" class="restore-panel" type="button" hidden>显示 Codex</button>
<!-- Protocol compatibility state stays out of the visual shell. -->
<section class="compatibility-state" aria-hidden="true" hidden inert>
<details id="sessionSettings">
<summary>会话设置</summary>
<div class="settings-grid">
<label>工作目录<input id="cwdInput" type="text" /></label>
<label>模型<input id="modelInput" type="text" placeholder="留空使用默认模型" /></label>
<label>沙箱
<select id="sandboxInput">
<option value="workspace-write">workspace-write</option>
<option value="read-only">read-only</option>
<option value="danger-full-access">danger-full-access</option>
</select>
</label>
<label>审批策略
<select id="approvalInput">
<option value="on-request">on-request</option>
<option value="untrusted">untrusted</option>
<option value="never">never</option>
</select>
</label>
<button id="startThreadButton" class="secondary" type="button">启动新 thread</button>
<div class="ids">
<span>thread</span><code id="threadId">-</code>
<span>turn</span><code id="turnId">-</code>
</div>
</div>
</details>
<details id="connectionSettings">
<summary>连接设置</summary>
<label class="token-field">
<span id="tokenLabel">本机连接(无需 token)</span>
<input id="tokenInput" type="password" autocomplete="off" placeholder="本机模式无需填写;认证模式再填写" />
</label>
</details>
<span id="sessionMode">已附着当前会话</span>
<span id="latestSeq">seq -</span>
<span id="outputHint">等待连接</span>
<button id="clearOutputButton" type="button">清空对话</button>
<span id="lastEvent">-</span>
<details id="requestsPanel"><summary><span>授权与输入</span><span id="requestCount" class="badge warning">0</span></summary><div id="requests" class="requests empty">暂无待处理请求</div></details>
</section>
<template id="requestTemplate">
<article class="request">
<div class="request-title"><span class="request-icon" aria-hidden="true">!</span><strong class="request-method"></strong><span class="request-risk"></span><span class="request-id"></span></div>
<p class="request-summary"></p>
<pre class="request-command"></pre>
<div class="request-questions"></div>
<label class="request-scope-wrap" hidden>
<span>授权范围</span>
<select class="request-scope">
<option value="turn">仅本次 turn</option>
<option value="session">当前会话</option>
</select>
</label>
<details class="request-details">
<summary>查看请求数据</summary>
<pre class="request-json"></pre>
</details>
<textarea class="request-response" rows="4" aria-label="JSON 响应"></textarea>
<div class="button-row request-actions">
<button class="primary request-allow">允许</button>
<button class="secondary request-deny">拒绝</button>
<button class="secondary request-send">发送 JSON</button>
</div>
</article>
</template>
<script src="./embed-bridge.js" defer></script>
<script src="./i18n.js" defer></script>
<script src="./app.js" defer></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+745
View File
@@ -0,0 +1,745 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
class FakeRpc {
responses = [];
requests = [];
notificationListener;
requestListener;
exitListener;
overrides;
constructor(overrides = {}) {
this.overrides = overrides;
}
get running() {
return true;
}
async start() {}
async request(method, params) {
this.requests.push({ method, params });
if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
const override = this.overrides[method];
return typeof override === "function" ? override(params) : override;
}
if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
if (method === "turn/start") return { turn: { id: "turn-test" } };
if (method === "turn/steer") return { turn: { id: "turn-test" } };
if (method === "turn/interrupt") return {};
throw new Error(`unexpected request ${method}`);
}
notify() {}
respond(id, result) {
this.responses.push({ id, result });
}
respondError(id, code, message) {
this.responses.push({ id, error: { code, message } });
}
onNotification(listener) {
this.notificationListener = listener;
return { dispose: () => undefined };
}
onServerRequest(listener) {
this.requestListener = listener;
return { dispose: () => undefined };
}
onExit(listener) {
this.exitListener = listener;
return { dispose: () => undefined };
}
close() {}
emitRequest(request) {
this.requestListener(request);
}
emitNotification(notification) {
this.notificationListener(notification);
}
}
class FakeRelay {
frames = [];
listeners = new Set();
async connect() {}
send(frame) {
this.frames.push(frame);
}
onMessage(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
close() {}
}
test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 1,
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
});
rpc.emitRequest({
id: "1",
method: "item/commandExecution/requestApproval",
params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
});
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
await adapter.respondApproval(1, "deny");
await adapter.respondApproval("1", "deny");
assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
await adapter.dispose();
});
test("commandActions are included in high-risk approval classification", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
rpc.emitRequest({
id: 2,
method: "item/commandExecution/requestApproval",
params: {
threadId: "t",
turnId: "u",
itemId: "actions",
command: null,
commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
},
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.pendingApprovals[0].risk, "high");
await adapter.respondApproval(2, "deny");
await adapter.dispose();
});
test("output snapshots stay redacted and interrupt clears the active turn", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.startThread({});
await adapter.startTurn({ text: "hello" });
assert.equal((await adapter.snapshot()).turnId, "turn-test");
rpc.emitNotification({
method: "item/agentMessage/delta",
params: { delta: "credential Bearer abcdefghijklmnop" },
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
assert.match(snapshot.outputTail, /\[REDACTED\]/);
await adapter.interruptTurn({});
const afterInterrupt = await adapter.snapshot();
assert.equal(afterInterrupt.turnId, null);
assert.equal(afterInterrupt.state, "idle");
await adapter.dispose();
});
test("async adapter lists app-server threads and exposes the model catalog", async () => {
const rpc = new FakeRpc({
"model/list": {
data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
nextCursor: null,
},
"thread/list": {
data: [
{
id: "thread-recent",
name: null,
preview: "Inspect the workspace\nwith detail",
cwd: "/tmp/workspace",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
source: "vscode",
},
],
nextCursor: "next-page",
backwardsCursor: null,
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
assert.equal(result.sessions[0].threadId, "thread-recent");
assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
assert.equal(result.nextCursor, "next-page");
const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
assert.deepEqual(listRequest.params, {
limit: 100,
sortKey: "updated_at",
sortDirection: "desc",
searchTerm: "workspace",
});
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.mode, "async");
assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
await adapter.dispose();
});
test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.startThread({});
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
// A usage update may arrive after the turn has completed. It must not
// make the adapter report that historical turn as active again.
turnId: "turn-finished",
tokenUsage: {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
},
},
});
const expected = {
total: {
totalTokens: 1_200,
inputTokens: 800,
cachedInputTokens: 100,
cacheWriteInputTokens: 20,
outputTokens: 300,
reasoningOutputTokens: 80,
},
last: {
totalTokens: 450,
inputTokens: 300,
cachedInputTokens: 40,
cacheWriteInputTokens: 10,
outputTokens: 100,
reasoningOutputTokens: 40,
},
modelContextWindow: 128_000,
};
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.metadata.tokenUsage, expected);
assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
assert.equal(snapshot.turnId, null);
const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
assert.ok(usageEvent);
assert.deepEqual(usageEvent.payload.tokenUsage, expected);
assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
// Keep the raw diagnostic envelope redacted while exposing only the safe
// numeric projection to the browser.
assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
rpc.emitNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-test",
turnId: "turn-finished",
tokenUsage: { total: { inputTokens: -1 } },
},
});
assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
await adapter.dispose();
});
test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
const thread = {
id: "thread-selected",
name: "Selected thread",
preview: "hello",
cwd: "/tmp/selected",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_010,
status: { type: "idle" },
turns: [{
id: "turn-history",
status: "completed",
startedAt: 1_700_000_001,
completedAt: 1_700_000_004,
durationMs: 3_000,
items: [
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
{ type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
{ type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
{ type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
],
}],
};
const rpc = new FakeRpc({
"model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
"thread/resume": {
thread,
model: "gpt-5.6-sol",
modelProvider: "openai",
serviceTier: null,
cwd: "/tmp/selected",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: { type: "workspaceWrite" },
reasoningEffort: "high",
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
const events = [];
adapter.onEvent((event) => events.push(event));
await adapter.start();
const result = await adapter.selectSession({ threadId: "thread-selected" });
assert.equal(result.threadId, "thread-selected");
let snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.length, 4);
assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
assert.equal(snapshot.metadata.title, "Selected thread");
assert.equal(snapshot.metadata.threadSettings.effort, "high");
assert.equal(snapshot.metadata.historyComplete, true);
assert.equal(snapshot.status.turnStatus, "completed");
assert.match(snapshot.outputTail, /Done/);
assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
const authoritative = events.find((event) => event.type === "session.snapshot");
assert.equal(authoritative.payload.threadId, "thread-selected");
assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
assert.equal(authoritative.payload.messages.length, 4);
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-old",
turnId: "turn-old",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
},
});
rpc.emitNotification({
method: "item/completed",
params: {
threadId: "thread-selected",
turnId: "turn-live",
completedAtMs: Date.now(),
item: { type: "agentMessage", id: "current-item", text: "current thread" },
},
});
snapshot = await adapter.snapshot();
assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
await adapter.dispose();
});
test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
const threadId = "thread-paged-history";
const userItem = (id, text) => ({
type: "userMessage",
id,
clientId: null,
content: [{ type: "text", text, text_elements: [] }],
});
const assistantItem = (id, text) => ({
type: "agentMessage",
id,
text,
phase: "final_answer",
});
const earlyUser = userItem("early-user", "first question");
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": {
thread: {
id: threadId,
name: "Paged history",
preview: "first question",
cwd: "/tmp/paged",
createdAt: 50,
updatedAt: 350,
historyMode: "paginated",
status: { type: "idle" },
turns: [],
},
model: "gpt-5.6-sol",
cwd: "/tmp/paged",
initialTurnsPage: {
data: [{
id: "turn-late",
status: "completed",
startedAt: 300,
completedAt: 310,
itemsView: "full",
items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
}],
nextCursor: "turn-page-2",
backwardsCursor: null,
},
},
"thread/turns/list": (params) => {
if (params.cursor === "turn-page-2") {
return {
data: [{
id: "turn-early",
status: "completed",
startedAt: 100,
completedAt: 110,
itemsView: "summary",
items: [earlyUser],
}],
nextCursor: "turn-page-3",
backwardsCursor: null,
};
}
assert.equal(params.cursor, "turn-page-3");
return {
data: [{
id: "turn-middle",
status: "completed",
startedAt: 200,
completedAt: 210,
itemsView: "full",
items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
}],
nextCursor: null,
backwardsCursor: null,
};
},
"thread/items/list": (params) => {
assert.equal(params.turnId, "turn-early");
return {
data: [
// The summary row is repeated by the full item page; hydration must
// de-duplicate it while adding the omitted assistant response.
{ turnId: "turn-early", item: earlyUser },
{ turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
],
nextCursor: null,
backwardsCursor: null,
};
},
});
const events = [];
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
adapter.onEvent((event) => events.push(event));
await adapter.start();
await adapter.selectSession({ threadId });
const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
assert.deepEqual(resume.params, {
threadId,
excludeTurns: true,
initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
});
const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
&& entry.params.limit === 100
&& entry.params.sortDirection === "asc"
&& entry.params.itemsView === "full"));
const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
assert.deepEqual(itemPages.map((entry) => entry.params), [{
threadId,
turnId: "turn-early",
limit: 100,
sortDirection: "asc",
}]);
assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
const snapshot = await adapter.snapshot();
assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
["turn-early", "first question"],
["turn-early", "first answer"],
["turn-middle", "second question"],
["turn-middle", "second answer"],
["turn-late", "third question"],
["turn-late", "third answer"],
]);
assert.equal(snapshot.metadata.historyComplete, true);
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
assert.equal(outputSnapshot.payload.historyComplete, true);
assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
"first question",
"first answer",
"second question",
"second answer",
"third question",
"third answer",
]);
await adapter.dispose();
});
test("async adapter falls back to thread/read when resume omits existing history", async () => {
const metadataThread = {
id: "thread-paginated",
preview: "existing conversation",
cwd: "/tmp/project",
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
status: { type: "idle" },
turns: [],
};
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
"thread/read": {
thread: {
...metadataThread,
turns: [{
id: "turn-read",
status: "completed",
items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
}],
},
},
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
await adapter.selectSession({ threadId: "thread-paginated" });
const read = rpc.requests.find((entry) => entry.method === "thread/read");
assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
await adapter.dispose();
});
test("async adapter starts new sessions and sends flat durable thread settings", async () => {
const rpc = new FakeRpc({
"model/list": { data: [], nextCursor: null },
"thread/start": {
thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
model: "gpt-5.6-sol",
cwd: "/tmp/new",
reasoningEffort: "medium",
},
"thread/settings/update": { ok: true },
});
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
await adapter.start();
await adapter.newSession({});
await adapter.updateThreadSettings({
threadSettings: {
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandboxPolicy: "workspace-write",
permissions: ":workspace",
},
});
const start = rpc.requests.find((entry) => entry.method === "thread/start");
assert.equal(start.params.cwd, "/tmp/default");
const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
assert.deepEqual(update.params, {
threadId: "thread-new",
model: "gpt-5.6-terra",
effort: "high",
approvalPolicy: "on-request",
approvalsReviewer: "user",
permissions: ":workspace",
});
assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
assert.equal(snapshot.metadata.latestReasoningEffort, "high");
assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
await adapter.dispose();
});
test("thread settings updates require the send_task_input capability", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output"],
sessionId: "test-session",
});
await host.handleFrame({
kind: "command",
type: "thread.settings.update",
commandId: "settings-without-capability",
actor: { role: "operator" },
payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
});
const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
assert.equal(result.type, "command.rejected");
assert.match(result.payload.error, /missing capability: send_task_input/);
await adapter.dispose();
});
test("RelayHost exposes session list as read-only and protects session selection", async () => {
const relay = new FakeRelay();
const calls = [];
const adapter = {
async start() {},
async sendInput() { return {}; },
async cancel() { return {}; },
async respondApproval() { return {}; },
async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
onEvent() { return { dispose() {} }; },
async dispose() {},
async listSessions(params) {
calls.push({ method: "listSessions", params });
return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
},
async selectSession(params) {
calls.push({ method: "selectSession", params });
return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
},
async newSession(params) {
calls.push({ method: "newSession", params });
return { opened: true, command: "chatgpt.newCodexPanel" };
},
getControlMode() { return "sync"; },
async setControlMode(params) {
calls.push({ method: "setControlMode", params });
return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
},
};
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input"],
sessionId: "test-session",
});
await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
assert.equal(listed.type, "command.accepted");
assert.equal(listed.payload.result.activeThreadId, "thread-a");
assert.equal(calls[0].method, "listSessions");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
assert.equal(denied.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
assert.equal(selected.type, "command.accepted");
assert.equal(selected.payload.result.threadId, "thread-b");
assert.equal(calls.at(-1).method, "selectSession");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
assert.equal(deniedNew.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
assert.equal(opened.type, "command.accepted");
assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
assert.equal(calls.at(-1).method, "newSession");
await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
assert.equal(mode.type, "command.accepted");
assert.equal(mode.payload.result.mode, "sync");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
assert.equal(deniedMode.type, "command.rejected");
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
assert.equal(changedMode.type, "command.accepted");
assert.equal(changedMode.payload.result.controlMode, "async");
assert.equal(calls.at(-1).method, "setControlMode");
});
test("approval decision conflicts and unknown tagged objects fail closed", async () => {
const rpc = new FakeRpc();
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
await adapter.start();
const relay = new FakeRelay();
const host = new RelayHost({
adapter,
relay,
capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
sessionId: "test-session",
});
rpc.emitRequest({
id: 3,
method: "execCommandApproval",
params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "conflicting-response",
actor: { role: "operator" },
payload: {
requestId: 3,
decision: "deny",
response: { decision: "approved_mcp_policy_amendment" },
},
});
assert.deepEqual(rpc.responses[0], {
id: 3,
result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
});
rpc.emitRequest({
id: 4,
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
});
await host.handleFrame({
kind: "command",
type: "approval.respond",
commandId: "unknown-tagged-response",
actor: { role: "operator" },
payload: {
requestId: 4,
decision: "allow",
response: { decision: { futurePolicyGrant: { scope: "all" } } },
},
});
assert.deepEqual(rpc.responses[1], {
id: 4,
result: { decision: "decline" },
});
await adapter.dispose();
});
+298
View File
@@ -0,0 +1,298 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { WebSocket } = require("ws");
const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js");
const internalToken = "test-internal-token-with-enough-entropy";
function internalFetch(base, pathname, options = {}) {
return fetch(`${base}${pathname}`, {
...options,
headers: {
Authorization: `Bearer ${internalToken}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
}
function websocketClient(base, clientType, token, sessionId) {
const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const messages = [];
const waiters = [];
const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => {
const existing = messages.find(predicate);
if (existing) return resolve(existing);
const timer = setTimeout(() => {
const index = waiters.findIndex((entry) => entry.resolve === resolve);
if (index >= 0) waiters.splice(index, 1);
reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`));
}, timeout);
waiters.push({
predicate,
resolve: (message) => {
clearTimeout(timer);
resolve(message);
},
});
});
socket.on("message", (data) => {
const message = JSON.parse(data.toString("utf8"));
messages.push(message);
for (let index = waiters.length - 1; index >= 0; index -= 1) {
if (!waiters[index].predicate(message)) continue;
const waiter = waiters.splice(index, 1)[0];
waiter.resolve(message);
}
});
return new Promise((resolve, reject) => {
socket.once("open", () => {
socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) }));
socket.send(JSON.stringify(clientType === "host"
? { v: 1, kind: "auth", accessToken: token }
: { type: "auth", token }));
wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject);
});
socket.once("error", reject);
});
}
async function pairDevice(base, userId, name) {
const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, {
method: "POST",
body: JSON.stringify({ name }),
});
assert.equal(pairingResponse.status, 201);
const pairing = await pairingResponse.json();
const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: pairing.code, name }),
});
assert.equal(exchangeResponse.status, 201);
return exchangeResponse.json();
}
async function browserTicket(base, userId, deviceId) {
const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId }),
});
assert.equal(response.status, 201);
return response.json();
}
function exchangeAttempt(base, headers = {}) {
return fetch(`${base}/v1/pairings/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ code: "INVALID-CODE" }),
});
}
test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const trustedHeaders = (clientIp) => ({
Authorization: `Bearer ${internalToken}`,
"X-Aether-Client-IP": clientIp,
});
for (let attempt = 0; attempt < 10; attempt += 1) {
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400);
}
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429);
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400);
assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400);
server.exchangeAttempts.clear();
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400);
}
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal((await exchangeAttempt(base, {
Authorization: "Bearer invalid-internal-token",
"X-Aether-Client-IP": `198.51.100.${30 + attempt}`,
})).status, 400);
}
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429);
server.exchangeAttempts.clear();
const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"];
for (let attempt = 0; attempt < 10; attempt += 1) {
assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400);
}
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429);
});
test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
pairingTtlMs: 5_000,
ticketTtlMs: 5_000,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`);
assert.equal(unauthorized.status, 401);
const paired = await pairDevice(base, "user-a", "MacBook VS Code");
assert.match(paired.device_token, /^avx1\./);
const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices");
assert.equal(devicesResponse.status, 200);
const devices = await devicesResponse.json();
assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [
{ id: paired.device_id, name: "MacBook VS Code", connected: false },
]);
const ticket = await browserTicket(base, "user-a", paired.device_id);
assert.equal(ticket.ws_url, "/api/vscodex/ws");
const host = await websocketClient(base, "host", paired.device_token, "host-user-a");
const browser = await websocketClient(base, "web", ticket.ticket);
t.after(() => host.socket.close());
t.after(() => browser.socket.close());
browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
host.socket.send(JSON.stringify({
v: 1,
kind: "event",
type: "connection.opened",
id: "connection-a",
sessionId: "host-user-a",
seq: 1,
ts: new Date().toISOString(),
payload: {},
}));
host.socket.send(JSON.stringify({
v: 1,
kind: "event",
type: "session.snapshot",
id: "snapshot-a",
sessionId: "host-user-a",
seq: 2,
ts: new Date().toISOString(),
payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] },
}));
const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot");
assert.equal(snapshot.payload.threadId, "thread-a");
assert.equal(snapshot.payload.messages[0].text, "user-a-only");
browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} }));
const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command");
assert.equal(command.type, "session/list");
const secondUser = await pairDevice(base, "user-b", "Other VS Code");
const secondTicket = await browserTicket(base, "user-b", secondUser.device_id);
const secondBrowser = await websocketClient(base, "web", secondTicket.ticket);
t.after(() => secondBrowser.socket.close());
secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false);
const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const closed = new Promise((resolve, reject) => {
reusedTicket.once("open", () => {
reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket }));
});
reusedTicket.once("close", (code) => resolve(code));
reusedTicket.once("error", reject);
});
assert.equal(await closed, 1008, "browser tickets are one-time credentials");
});
test("device revocation closes its room and blocks future host authentication", async (t) => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-"));
const server = new AetherVscodexCloudServer({
host: "127.0.0.1",
port: 0,
internalToken,
publicWsUrl: "wss://aether.example/api/vscodex/ws",
dataDir,
});
await server.start();
t.after(async () => {
await server.stop();
fs.rmSync(dataDir, { recursive: true, force: true });
});
const address = server.address();
const base = `http://127.0.0.1:${address.port}`;
const paired = await pairDevice(base, "user-a", "Revoked device");
const host = await websocketClient(base, "host", paired.device_token, "revoked-host");
const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" });
assert.equal(response.status, 204);
await new Promise((resolve) => host.socket.once("close", resolve));
const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
const closed = new Promise((resolve, reject) => {
rejected.once("open", () => {
rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" }));
rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token }));
});
rejected.once("close", (code) => resolve(code));
rejected.once("error", reject);
});
assert.equal(await closed, 1008);
});
test("room revocation wins a concurrent room creation", async () => {
const rooms = new RoomManager();
let releaseCreation;
const creationGate = new Promise((resolve) => { releaseCreation = resolve; });
let stopped = false;
const room = {
key: rooms.key("user-a", "device-a"),
userId: "user-a",
deviceId: "device-a",
relay: { stop: async () => { stopped = true; } },
connections: 0,
lastActiveMs: Date.now(),
};
rooms.createRoom = async (key) => {
await creationGate;
rooms.rooms.set(key, room);
return room;
};
const pendingGet = rooms.get("user-a", "device-a");
await new Promise((resolve) => setImmediate(resolve));
const pendingRevoke = rooms.revoke("user-a", "device-a");
releaseCreation();
await assert.rejects(pendingGet, /device revoked/);
await pendingRevoke;
assert.equal(stopped, true);
assert.equal(rooms.rooms.has(room.key), false);
await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/);
});
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
"use strict";
const assert = require("node:assert/strict");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { mkdtempSync, rmSync } = require("node:fs");
const test = require("node:test");
const {
CODEX_IPC_METHOD_VERSIONS,
CodexIpcClient,
IpcFrameDecoder,
applyIpcPatches,
encodeIpcFrame,
} = require("../vscode-extension/dist/codexIpc.js");
function waitFor(predicate, timeoutMs = 2_000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const poll = () => {
if (predicate()) return resolve();
if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture"));
setTimeout(poll, 5);
};
poll();
});
}
test("private IPC framing handles split UTF-8 frames", () => {
const message = {
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: "client-1",
version: 1,
params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" },
};
const frame = encodeIpcFrame(message);
const decoder = new IpcFrameDecoder();
const first = decoder.push(frame.subarray(0, 3));
assert.deepEqual(first, []);
const second = decoder.push(frame.subarray(3, frame.length - 1));
assert.deepEqual(second, []);
assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]);
});
test("applyIpcPatches updates a conversation snapshot", () => {
const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" };
const next = applyIpcPatches(initial, [
{ op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" },
{ op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } },
{ op: "replace", path: ["status"], value: "active" },
]);
assert.deepEqual(next, {
turns: [{ items: [{ text: "new" }, { text: "second" }] }],
status: "active",
});
});
test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => {
const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-"));
const socketPath = path.join(temp, "ipc.sock");
const threadId = "11111111-1111-4111-8111-111111111111";
const ownerId = "owner-client";
const requests = [];
const followingBroadcasts = [];
let fixtureSocket;
const server = net.createServer((socket) => {
fixtureSocket = socket;
const decoder = new IpcFrameDecoder();
socket.on("data", (chunk) => {
for (const message of decoder.push(chunk)) {
if (message.type === "request" && message.method === "initialize") {
socket.write(encodeIpcFrame({
type: "response",
requestId: message.requestId,
resultType: "success",
method: "initialize",
handledByClientId: "fixture-client",
result: { clientId: "fixture-client" },
}));
continue;
}
if (message.type === "broadcast" && message.method === "thread-stream-following-changed") {
followingBroadcasts.push(message);
const target = message.sourceClientId;
socket.write(encodeIpcFrame({
type: "broadcast",
method: "thread-stream-state-changed",
sourceClientId: ownerId,
targetClientIds: [target],
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"],
params: {
conversationId: threadId,
hostId: "local",
change: {
type: "snapshot",
revision: 1,
conversationState: { id: threadId, title: "fixture", turns: [], requests: [] },
},
},
}));
continue;
}
if (message.type === "request") {
requests.push(message);
socket.write(encodeIpcFrame({
type: "response",
requestId: message.requestId,
resultType: "success",
method: message.method,
handledByClientId: ownerId,
result: { method: message.method, ok: true },
}));
}
}
});
});
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const client = new CodexIpcClient({ socketPath, autoReconnect: false });
const streamEvents = [];
client.onStreamEvent((event) => streamEvents.push(event));
await client.connect();
await client.followConversation(threadId);
await waitFor(() => streamEvents.some((event) => event.kind === "snapshot"));
assert.equal(client.getConversationState(threadId).ownerClientId, ownerId);
fixtureSocket.write(encodeIpcFrame({
type: "broadcast",
method: "thread-stream-following-status-requested",
sourceClientId: ownerId,
targetClientIds: ["fixture-client"],
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"],
params: { conversationId: threadId, hostId: "local" },
}));
await waitFor(() => followingBroadcasts.length >= 2);
assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]);
assert.deepEqual(followingBroadcasts[1].params, {
conversationId: threadId,
hostId: "local",
following: true,
});
await client.startTurn(threadId, "hello", { ownerClientId: ownerId });
await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId });
await client.updateThreadSettings(threadId, {
model: "gpt-5.6-sol",
effort: "ultra",
multiAgentMode: "explicitRequestOnly",
}, { ownerClientId: ownerId });
await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId });
await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId });
await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId });
await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId });
await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId });
await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId });
assert.deepEqual(requests.map((request) => request.method), [
"thread-follower-start-turn",
"thread-follower-steer-turn",
"thread-follower-update-thread-settings",
"thread-follower-interrupt-turn",
"thread-follower-command-approval-decision",
"thread-follower-file-approval-decision",
"thread-follower-permissions-request-approval-response",
"thread-follower-submit-user-input",
"thread-follower-submit-mcp-server-elicitation-response",
]);
assert.deepEqual(requests[0].params, {
conversationId: threadId,
turnStart: {
request: {
threadId,
input: [{ type: "text", text: "hello", text_elements: [] }],
},
context: { inheritThreadSettings: true },
},
});
assert.deepEqual(requests[2].params, {
conversationId: threadId,
threadSettings: {
model: "gpt-5.6-sol",
effort: "ultra",
multiAgentMode: "explicitRequestOnly",
},
});
assert.equal(requests[2].version, 1);
assert.deepEqual(requests[3].params, {
conversationId: threadId,
mode: "user-stop",
expectedTurnId: "turn-1",
});
assert.equal(requests[3].version, 4);
assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" });
assert.deepEqual(requests[8].params, {
conversationId: threadId,
requestId: 11,
response: { action: "decline", content: null, _meta: null },
});
await client.dispose();
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(temp, { recursive: true, force: true });
}
});
+57
View File
@@ -0,0 +1,57 @@
"use strict";
const assert = require("node:assert/strict");
const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js");
const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js");
function temporaryDirectory() {
return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-"));
}
test("resolveCodexCommand finds a bare command in PATH", () => {
const root = temporaryDirectory();
try {
const bin = path.join(root, "bin");
const executable = path.join(bin, "codex-test");
mkdirSync(bin);
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
chmodSync(executable, 0o755);
assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => {
const root = temporaryDirectory();
try {
const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex");
mkdirSync(path.dirname(executable), { recursive: true });
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
chmodSync(executable, 0o755);
assert.equal(
resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }),
executable,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a missing explicit command reports a full-path setting hint", () => {
assert.throws(
() => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }),
/Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/,
);
});
test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => {
const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] });
await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/);
client.close();
});
+105
View File
@@ -0,0 +1,105 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js");
class FakeRelay {
constructor({ connectError } = {}) {
this.connectError = connectError;
this.frames = [];
this.closed = false;
this.listeners = { message: new Set(), open: new Set(), close: new Set() };
}
async connect() {
if (this.connectError) throw this.connectError;
for (const listener of this.listeners.open) listener();
}
send(frame) { this.frames.push(frame); }
close() { this.closed = true; }
onMessage(listener) { return this.add("message", listener); }
onOpen(listener) { return this.add("open", listener); }
onClose(listener) { return this.add("close", listener); }
add(type, listener) {
this.listeners[type].add(listener);
return { dispose: () => this.listeners[type].delete(listener) };
}
receive(frame) { for (const listener of this.listeners.message) listener(frame); }
disconnect(error) { for (const listener of this.listeners.close) listener(error); }
}
test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay({ connectError: new Error("cloud offline") });
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
await relay.connect();
assert.equal(relay.isConnected("local"), true);
assert.equal(relay.isConnected("cloud"), false);
relay.send({ kind: "event", type: "session.snapshot" });
assert.equal(local.frames.length, 1);
assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect");
relay.close();
assert.equal(local.closed, true);
assert.equal(cloud.closed, true);
});
test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
const messages = [];
const closes = [];
relay.onMessage((frame) => messages.push(frame));
relay.onClose((error) => closes.push(error?.message));
await relay.connect();
assert.equal(relay.isConnected("local"), true);
assert.equal(relay.isConnected("cloud"), true);
cloud.receive({ kind: "command", type: "turn.start" });
assert.equal(messages.length, 1);
local.disconnect(new Error("local offline"));
assert.equal(relay.isConnected("local"), false);
assert.deepEqual(closes, []);
cloud.disconnect(new Error("cloud offline"));
assert.deepEqual(closes, ["cloud offline"]);
relay.close();
});
test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => {
const local = new FakeRelay();
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
let opens = 0;
relay.onOpen(() => { opens += 1; });
await relay.connect();
assert.equal(opens, 2);
cloud.disconnect(new Error("cloud offline"));
for (const listener of cloud.listeners.open) listener();
assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot");
relay.close();
});
test("CompositeRelayTransport fails when the required local relay cannot connect", async () => {
const local = new FakeRelay({ connectError: new Error("local offline") });
const cloud = new FakeRelay();
const relay = new CompositeRelayTransport([
{ id: "local", transport: local, required: true },
{ id: "cloud", transport: cloud },
]);
await assert.rejects(relay.connect(), /local: local offline/);
assert.equal(local.closed, true);
assert.equal(cloud.closed, true);
});
@@ -0,0 +1,34 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
test("VS Code runtime strings have English and Simplified Chinese bundles", () => {
const extensionRoot = path.join(__dirname, "..", "vscode-extension");
const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8");
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8"));
const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8"));
const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8"));
const keys = [...source.matchAll(/(?<![A-Za-z])t\("([^"]+)"/g)].map((match) => match[1]);
assert.equal(manifest.l10n, "./l10n");
assert.ok(keys.length > 20, "expected runtime-localized extension strings");
for (const key of new Set(keys)) {
assert.equal(english[key], key, `missing English source string: ${key}`);
assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`);
assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`);
}
});
test("production copy scripts require the Vue build instead of silently falling back", () => {
const projectRoot = path.join(__dirname, "..");
const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8");
const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8");
const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8"));
assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/);
assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/);
assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/);
});
+120
View File
@@ -0,0 +1,120 @@
const assert = require("node:assert/strict");
const http = require("node:http");
const path = require("node:path");
const test = require("node:test");
const {
LocalRelayController,
localRelayTarget,
relayHealthAvailable,
} = require("../vscode-extension/dist/localRelay.js");
test("local relay target accepts only loopback ws URLs", () => {
assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), {
host: "127.0.0.1",
port: 8898,
healthUrl: "http://127.0.0.1:8898/api/health",
webUrl: "http://127.0.0.1:8898/",
});
assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined);
assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined);
assert.equal(localRelayTarget("not a url"), undefined);
});
test("local relay health probe recognizes a responding HTTP service", async (t) => {
const server = http.createServer((request, response) => {
if (request.url === "/api/health") {
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
} else if (request.url === "/aborted") {
response.writeHead(200, { "content-type": "application/json" });
response.write('{"ok":');
response.destroy();
} else if (request.url === "/drip") {
response.writeHead(200, { "content-type": "application/json" });
const interval = setInterval(() => response.write(" "), 10);
response.on("close", () => clearInterval(interval));
} else {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true);
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false);
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false);
const startedAt = Date.now();
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false);
assert.ok(Date.now() - startedAt < 500);
});
test("local relay controller starts and stops a bundled loopback relay", async () => {
let starts = 0;
let stops = 0;
class FakeRelay {
async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; }
async stop() { stops += 1; }
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
probeTimeoutMs: 20,
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
});
assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true);
assert.equal(starts, 1);
await controller.stop();
assert.equal(stops, 1);
});
test("local relay controller does not leak a relay when stopped during startup", async () => {
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
let startEntered;
const entered = new Promise((resolve) => { startEntered = resolve; });
let stops = 0;
class SlowRelay {
async start() {
startEntered();
await startGate;
return { host: "127.0.0.1", port: 65533 };
}
async stop() { stops += 1; }
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
probeTimeoutMs: 20,
loadRelayModule: () => ({ CodexRelay: SlowRelay }),
});
const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect");
await entered;
const stopping = controller.stop();
releaseStart();
await Promise.all([starting, stopping]);
assert.equal(stops, 1);
});
test("local relay controller does not start after stop wins an in-flight health probe", async () => {
let resolveProbe;
const probe = new Promise((resolve) => { resolveProbe = resolve; });
let probeEntered;
const entered = new Promise((resolve) => { probeEntered = resolve; });
let starts = 0;
class FakeRelay {
async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; }
async stop() {}
}
const controller = new LocalRelayController({
extensionPath: path.resolve(__dirname, "../vscode-extension"),
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
probeRelayHealth: async () => {
probeEntered();
return probe;
},
});
const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect");
await entered;
await controller.stop();
resolveProbe(false);
assert.equal(await ensuring, false);
assert.equal(starts, 0);
});
@@ -0,0 +1,194 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js");
const i18n = require("../public/i18n.js");
function embeddedWindow() {
const listeners = new Map();
const posts = [];
const parent = { postMessage: (message, origin) => posts.push({ message, origin }) };
const bodyClasses = new Set();
const documentElement = { dataset: {}, style: {} };
const windowLike = {
location: { search: "?embed=aether", origin: "https://aether.example" },
parent,
document: {
body: { classList: { add: (value) => bodyClasses.add(value) } },
documentElement,
},
addEventListener: (name, listener) => listeners.set(name, listener),
removeEventListener: (name, listener) => {
if (listeners.get(name) === listener) listeners.delete(name);
},
};
return { bodyClasses, documentElement, listeners, parent, posts, windowLike };
}
test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => {
assert.equal(isAetherEmbed({ search: "" }), false);
assert.equal(isAetherEmbed({ search: "?embed=other" }), false);
assert.equal(isAetherEmbed({ search: "?embed=aether" }), true);
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
assert.equal(bridge.active, true);
bridge.start();
assert.equal(fixture.bodyClasses.has("embed-aether"), true);
assert.deepEqual(fixture.posts, [{
message: { v: 1, type: "aether-vscodex/ready" },
origin: "https://aether.example",
}]);
});
test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => {
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
bridge.start();
const dispatch = fixture.listeners.get("message");
const connect = {
v: 1,
type: "aether-vscodex/connect",
ticket: "one-time-ticket",
wsUrl: "/api/vscodex/ws",
locale: "en-US",
theme: "dark",
};
dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect });
dispatch({ origin: "https://aether.example", source: {}, data: connect });
let received = null;
bridge.on("connect", (message) => { received = message; });
assert.equal(received, null);
dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect });
assert.equal(received.ticket, "one-time-ticket");
assert.equal(fixture.documentElement.dataset.theme, "dark");
const second = embeddedWindow();
const bufferedBridge = createAetherEmbedBridge(second.windowLike);
bufferedBridge.start();
second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect });
let buffered = null;
bufferedBridge.on("connect", (message) => { buffered = message; });
assert.equal(buffered.ticket, "one-time-ticket");
});
test("bridge ticket requests never place the ticket in a URL", () => {
const fixture = embeddedWindow();
const bridge = createAetherEmbedBridge(fixture.windowLike);
bridge.start();
bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" });
assert.deepEqual(fixture.posts.at(-1), {
message: {
v: 1,
type: "aether-vscodex/request-ticket",
reason: "disconnected",
deviceId: "device-1",
},
origin: "https://aether.example",
});
});
test("locale dictionary covers static shell and core dynamic status text", () => {
assert.equal(i18n.translate("设置", "en-US"), "Settings");
assert.equal(i18n.translate("中文", "en-US"), "Chinese");
assert.equal(i18n.translate("正在思考", "en-US"), "Thinking");
assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files");
assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s");
assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed");
assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout");
assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host");
assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md");
assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out");
assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)");
assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed");
assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s");
assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s");
assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi");
assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s");
assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed");
assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted");
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置");
assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置");
assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s");
assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”...");
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s");
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files");
assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s");
assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan");
assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s");
assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)");
assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative");
assert.equal(i18n.translate("控制模式", "en-US"), "Control mode");
assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation");
assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently");
assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes");
assert.equal(i18n.translate("Settings", "zh-CN"), "设置");
assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN");
assert.equal(i18n.normalizeLocale("en-GB"), "en-US");
});
test("renderer-owned dynamic labels have English fallbacks without translating host values", () => {
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8");
// Command/path/title values are appended after a locale-specific prefix;
// they are never passed through the translator as a whole.
assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/);
assert.match(app, /uiWithRaw\("已读取 ", "Read ",/);
assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/);
});
test("public shell uses relative assets and embedded startup skips the health probe", () => {
const publicRoot = path.join(__dirname, "..", "public");
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
assert.match(html, /href="\.\/style\.css"/);
assert.match(html, /src="\.\/embed-bridge\.js"/);
assert.match(html, /src="\.\/i18n\.js"/);
assert.match(html, /src="\.\/app\.js"/);
assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/);
assert.doesNotMatch(app, /ticket=.*state\.embedTicket/);
assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/);
assert.match(app, /outputContent\.textContent = t\("无输出"\)/);
assert.match(app, /button\.title = t\(title\)/);
assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/);
assert.match(app, /return t\("需要远程确认或输入"\)/);
assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/);
assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/);
assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/);
});
test("control mode is snapshot-authoritative and gates independent session actions", () => {
const publicRoot = path.join(__dirname, "..", "public");
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/);
assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/);
assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/);
assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/);
assert.match(app, /sessionList: source\.sessionList === true/);
assert.match(app, /Boolean\(state\.sessionListCommandId\)/);
assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/);
assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/);
assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/);
assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/);
assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/);
assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/);
assert.match(app, /sessionPickerButton\.disabled = !listAllowed/);
});
+206
View File
@@ -0,0 +1,206 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const { RelayClient } = require("../vscode-extension/dist/relayClient.js");
class FakeWebSocket extends EventEmitter {
static instances = [];
constructor(url) {
super();
this.url = url;
this.readyState = 0;
this.sent = [];
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = 1;
this.emit("open");
}
receive(frame) {
this.emit("message", Buffer.from(JSON.stringify(frame)));
}
send(data) {
this.sent.push(JSON.parse(data));
}
close() {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
}
}
test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const firstConnect = client.connect();
const first = FakeWebSocket.instances[0];
first.open();
assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]);
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } });
assert.equal(first.sent.length, 2, "application event must not be sent before authentication");
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
await firstConnect;
assert.equal(first.sent.length, 3);
assert.equal(first.sent[2].id, "event-1");
first.close();
const secondConnect = client.connect();
const second = FakeWebSocket.instances[1];
second.open();
assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]);
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } });
assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated");
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
await secondConnect;
assert.equal(second.sent.length, 3);
assert.equal(second.sent[2].id, "event-2");
});
test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
maxFrameBytes: 4_096,
maxQueuedBytes: 4_096,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } });
client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } });
client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } });
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } });
assert.ok(client.queueBytes <= 4_096);
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
await connecting;
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]);
});
test("RelayClient evicts reconstructible projections before queued control events", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
maxFrameBytes: 4_096,
maxQueuedBytes: 2_500,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } });
client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } });
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } });
assert.ok(client.queueBytes <= 2_500);
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
await connecting;
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
assert.deepEqual(queuedIds, ["approval", "command"]);
});
test("RelayClient supports a tokenless local handshake", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://127.0.0.1:8787/v1/connect",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]);
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
await connecting;
client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} });
assert.equal(socket.sent.length, 2);
assert.equal(socket.sent[1].type, "connection.opened");
});
test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://127.0.0.1:8787/v1/connect",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const connecting = client.connect();
const socket = FakeWebSocket.instances[0];
socket.open();
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
await connecting;
const historyText = "x".repeat(512 * 1024);
assert.doesNotThrow(() => client.send({
v: 1,
kind: "event",
type: "session.snapshot",
id: "large-history-snapshot",
sessionId: "session-local",
payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] },
}));
assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length);
});
test("RelayClient ignores late events from a replaced socket", async (t) => {
FakeWebSocket.instances.length = 0;
const client = new RelayClient({
url: "ws://relay.invalid/v1/connect",
accessToken: "host-token",
reconnect: false,
webSocket: FakeWebSocket,
});
t.after(() => client.close());
const firstConnect = client.connect();
const first = FakeWebSocket.instances[0];
first.open();
client.close();
const secondConnect = client.connect();
const second = FakeWebSocket.instances[1];
second.open();
// Simulate a delayed event from the old socket after the replacement.
first.open();
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket");
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } });
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
await secondConnect;
assert.equal(second.sent[2].id, "event-after-replace");
await assert.rejects(firstConnect);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js");
class FakeAdapter {
constructor(name, options = {}) {
this.name = name;
this.options = options;
this.listeners = new Set();
this.calls = [];
this.disposed = false;
this.snapshotValue = options.snapshot ?? idleSnapshot(name);
}
async start() {
this.calls.push(["start"]);
this.emit({ type: "candidate.starting", payload: { name: this.name } });
if (this.options.startGate) await this.options.startGate.promise;
if (this.options.startError) throw this.options.startError;
this.emit({ type: "connection.opened", payload: { name: this.name } });
}
async startThread(params = {}) { return this.record("startThread", params); }
async newSession(params = {}) { return this.record("newSession", params); }
async startTurn(params) { return this.record("startTurn", params); }
async steerTurn(params) { return this.record("steerTurn", params); }
async updateThreadSettings(params) { return this.record("updateThreadSettings", params); }
async listSessions(params = {}) { return this.record("listSessions", params); }
async selectSession(params) { return this.record("selectSession", params); }
async interruptTurn(params) { return this.record("interruptTurn", params); }
async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); }
async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); }
async respondApproval(requestId, decision, reason, response) {
return this.record("respondApproval", { requestId, decision, reason, response });
}
async denyPending(reason) { this.calls.push(["denyPending", reason]); }
async snapshot() {
this.calls.push(["snapshot"]);
return structuredClone(this.snapshotValue);
}
onEvent(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
emit(event) {
for (const listener of this.listeners) listener(event);
}
async dispose() {
this.calls.push(["dispose"]);
this.disposed = true;
}
record(method, params) {
this.calls.push([method, params]);
return { adapter: this.name, method, params };
}
}
function idleSnapshot(name) {
return {
threadId: `${name}-thread`,
turnId: null,
state: "idle",
pendingApprovals: [],
pendingRequests: [],
outputTail: "",
metadata: { adapter: name },
};
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => {
const sync = new FakeAdapter("sync");
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
await adapter.start();
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.adapter, "sync");
assert.equal(snapshot.metadata.mode, "sync");
assert.equal(snapshot.metadata.controlMode, "sync");
assert.equal(snapshot.metadata.modeEpoch, 0);
assert.deepEqual(snapshot.metadata.capabilities, {
followsVscodeRoute: true,
sessionList: false,
sessionSelect: false,
sessionCreate: false,
threadSettings: true,
});
await assert.rejects(adapter.listSessions(), /unavailable in sync mode/);
await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/);
await assert.rejects(adapter.newSession(), /unavailable in sync mode/);
await assert.rejects(adapter.startThread(), /unavailable in sync mode/);
assert.equal((await adapter.sendInput("hello")).adapter, "sync");
assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync");
await adapter.dispose();
});
test("async mode proxies the complete AgentAdapter surface", async () => {
const independent = new FakeAdapter("async");
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
await adapter.start();
await adapter.startThread({ cwd: "/workspace" });
await adapter.newSession({ model: "codex" });
await adapter.startTurn({ text: "start" });
await adapter.steerTurn({ text: "steer" });
await adapter.updateThreadSettings({ effort: "high" });
await adapter.listSessions({ limit: 10 });
await adapter.selectSession({ threadId: "thread-2" });
await adapter.interruptTurn({ turnId: "turn-1" });
await adapter.sendInput("input", { source: "web" });
await adapter.cancel("turn-2", { reason: "user" });
await adapter.respondApproval(7, "allow", "approved", { decision: "accept" });
await adapter.denyPending("offline");
assert.deepEqual(
independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)),
[
"startThread",
"newSession",
"startTurn",
"steerTurn",
"updateThreadSettings",
"listSessions",
"selectSession",
"interruptTurn",
"sendInput",
"cancel",
"respondApproval",
"denyPending",
],
);
await adapter.dispose();
});
test("session/new falls back to thread/start for a minimal async adapter", async () => {
const independent = new FakeAdapter("async");
independent.newSession = undefined;
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
await adapter.start();
const result = await adapter.newSession({ cwd: "/workspace" });
assert.equal(result.method, "startThread");
assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true);
await adapter.dispose();
});
test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => {
const sync = new FakeAdapter("sync");
const gate = deferred();
const asyncAdapter = new FakeAdapter("async", { startGate: gate });
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
});
const events = [];
adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`));
await adapter.start();
events.length = 0;
const switching = adapter.setControlMode({ mode: "async" });
await Promise.resolve();
sync.emit({ type: "old.while-current", payload: { name: "sync" } });
assert.deepEqual(events, ["old.while-current:sync"]);
await assert.rejects(adapter.sendInput("racing input"), /mode is switching/);
gate.resolve();
const result = await switching;
assert.deepEqual(result, {
changed: true,
controlMode: "async",
previousControlMode: "sync",
modeEpoch: 1,
});
assert.equal(sync.disposed, true);
assert.equal(adapter.getControlMode(), "async");
assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async"));
assert.ok(events.includes("connection.opened:async"));
sync.emit({ type: "old.after-commit", payload: { name: "sync" } });
asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } });
assert.equal(events.includes("old.after-commit:sync"), false);
assert.equal(events.includes("new.after-commit:async"), true);
const snapshot = await adapter.snapshot();
assert.equal(snapshot.metadata.modeEpoch, 1);
assert.deepEqual(snapshot.metadata.capabilities, {
followsVscodeRoute: false,
sessionList: true,
sessionSelect: true,
sessionCreate: true,
threadSettings: true,
});
assert.equal((await adapter.listSessions()).adapter, "async");
assert.equal((await adapter.newSession()).adapter, "async");
await adapter.dispose();
});
test("delegate snapshot events always carry authoritative mode metadata", async () => {
const sync = new FakeAdapter("sync");
const asyncAdapter = new FakeAdapter("async");
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
});
const snapshots = [];
adapter.onEvent((event) => {
if (event.type === "session.snapshot") snapshots.push(event.payload);
});
await adapter.start();
sync.emit({
type: "session.snapshot",
threadId: "sync-thread-2",
payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } },
});
assert.deepEqual(snapshots.at(-1).metadata, {
adapter: "sync",
route: "/thread/2",
mode: "sync",
controlMode: "sync",
modeEpoch: 0,
capabilities: {
followsVscodeRoute: true,
sessionList: false,
sessionSelect: false,
sessionCreate: false,
threadSettings: true,
},
});
await adapter.setControlMode({ mode: "async" });
snapshots.length = 0;
asyncAdapter.emit({
type: "session.snapshot",
threadId: "async-thread-2",
payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } },
});
assert.equal(snapshots.length, 1);
assert.equal(snapshots[0].metadata.adapter, "async");
assert.equal(snapshots[0].metadata.title, "Second");
assert.equal(snapshots[0].metadata.controlMode, "async");
assert.equal(snapshots[0].metadata.modeEpoch, 1);
assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false);
assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true);
await adapter.dispose();
});
test("active turns and pending requests prevent a mode switch", async (t) => {
const cases = [
["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }],
["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }],
["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }],
["pending approval", {
...idleSnapshot("sync"),
pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }],
}],
["pending input", {
...idleSnapshot("sync"),
pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }],
}],
];
for (const [name, snapshot] of cases) {
await t.test(name, async () => {
const sync = new FakeAdapter("sync", { snapshot });
let factoryCalls = 0;
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => {
factoryCalls += 1;
return mode === "sync" ? sync : new FakeAdapter("async");
},
});
await adapter.start();
await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/);
assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter");
assert.equal(adapter.getControlMode(), "sync");
await adapter.dispose();
});
}
});
test("candidate startup failure leaves the old adapter authoritative", async () => {
const sync = new FakeAdapter("sync");
const failed = new FakeAdapter("async", { startError: new Error("candidate failed") });
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => mode === "sync" ? sync : failed,
});
const events = [];
adapter.onEvent((event) => events.push(event.type));
await adapter.start();
events.length = 0;
await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/);
assert.equal(adapter.getControlMode(), "sync");
assert.equal(failed.disposed, true);
assert.equal(sync.disposed, false);
assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private");
assert.equal((await adapter.sendInput("still attached")).adapter, "sync");
assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0);
await adapter.dispose();
});
test("a mode factory cannot reuse the currently active adapter instance", async () => {
const shared = new FakeAdapter("shared");
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared });
await adapter.start();
await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/);
assert.equal(adapter.getControlMode(), "sync");
assert.equal(shared.disposed, false);
assert.equal((await adapter.sendInput("still live")).adapter, "shared");
await adapter.dispose();
});
test("listener failures cannot turn a committed switch into a rejected command", async () => {
const sync = new FakeAdapter("sync");
const asyncAdapter = new FakeAdapter("async");
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
});
adapter.onEvent(() => { throw new Error("consumer failed"); });
await adapter.start();
const result = await adapter.setControlMode({ mode: "async" });
assert.equal(result.changed, true);
assert.equal(adapter.getControlMode(), "async");
assert.equal(sync.disposed, true);
await adapter.dispose();
});
test("a turn that appears while the candidate starts aborts before commit", async () => {
const sync = new FakeAdapter("sync");
const gate = deferred();
const candidate = new FakeAdapter("async", { startGate: gate });
const adapter = new SwitchableAgentAdapter({
initialMode: "sync",
createAdapter: (mode) => mode === "sync" ? sync : candidate,
});
await adapter.start();
const switching = adapter.setControlMode({ mode: "async" });
await Promise.resolve();
sync.snapshotValue.turnId = "turn-race";
sync.snapshotValue.state = "active";
gate.resolve();
await assert.rejects(switching, /turn or request is active/);
assert.equal(adapter.getControlMode(), "sync");
assert.equal(candidate.disposed, true);
assert.equal(sync.disposed, false);
sync.snapshotValue.turnId = null;
sync.snapshotValue.state = "idle";
await adapter.dispose();
});
test("control mode validation and idempotent switches are explicit", async () => {
const sync = new FakeAdapter("sync");
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
await adapter.start();
await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/);
assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), {
changed: false,
controlMode: "sync",
previousControlMode: "sync",
modeEpoch: 0,
});
await adapter.dispose();
});
@@ -0,0 +1,3 @@
node_modules/
dist/
*.vsix
@@ -0,0 +1,9 @@
src/**
.gitignore
tsconfig.json
**/*.map
node_modules/@types/**
node_modules/typescript/**
node_modules/.package-lock.json
*.tsbuildinfo
*.vsix
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Codex Remote Collaboration contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+237
View File
@@ -0,0 +1,237 @@
# Codex Remote Collaboration VS Code Bridge
This extension connects local and Aether relay channels to one switchable Codex
control host. **Synchronous mode** follows the conversation currently shown by
the official Codex VS Code extension through its private IPC protocol and does
not spawn a `codex` process. **Asynchronous mode** starts an independent
app-server and lets the Web UI list, resume, create, and select conversations.
The attached conversation remains visible and usable in the official Codex
panel. Remote operators can observe its output, submit a new turn or steer the
active turn, interrupt it, and answer supported approval/input requests.
The mode can be changed from the Web UI without reconnecting either relay.
Synchronous mode makes the official panel the only conversation-navigation
owner; asynchronous mode restores the browser history and new-conversation
actions. A running turn or pending request blocks mode changes.
## Requirements
- The official `openai.chatgpt` VS Code extension is installed and signed in.
- The target Codex conversation is open and owned by that extension.
- The bridge and official extension run as the same OS user. The default Unix
socket is `$CODEX_HOME/ipc/ipc.sock`, normally `~/.codex/ipc/ipc.sock`.
- For a loopback `ws://` URL, the extension starts and owns its bundled relay
automatically. Remote and `wss://` relay URLs remain externally hosted.
The IPC follower protocol is private and versioned, not a public OpenAI API.
An official extension update can require a compatible bridge update. Strict
stream-version checks are enabled by default so an unknown protocol fails
closed instead of being interpreted optimistically.
## Build and install
```sh
npm install
npm run check
npm run build
npx --yes @vscode/vsce package
code --install-extension codex-remote-collab-0.4.0.vsix --force
```
Run **Developer: Reload Window** after installing or replacing the VSIX.
## Configure control modes
For the local default, no separate relay command is required. The extension
starts the bundled relay on the host and port from `codexRemoteCollab.localRelayUrl`.
To run the development relay manually, disable
`codexRemoteCollab.autoStartLocalRelay` and use:
```sh
HOST=127.0.0.1 PORT=8787 CODEX_REMOTE_MODE=host npm start
```
To opt into authentication later, set `CODEX_REMOTE_AUTH=required` and the three
token variables before starting the relay.
Set the extension configuration:
```json
{
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
"codexRemoteCollab.controlMode": "sync",
"codexRemoteCollab.autoDiscoverThread": true,
"codexRemoteCollab.autoStart": true
}
```
Then:
1. Open the target conversation in the official Codex panel.
2. Reload VS Code once after installing the companion extension. The local relay and bridge start automatically; no token is needed for loopback. The status item opens the Web console and is not a connect/disconnect toggle.
3. Open the relay web console; it connects automatically on localhost. The web UI uses a
Codex-style conversation stream with a bottom composer; Enter sends and Shift+Enter
inserts a newline. There is no separate connect/disconnect step for the local relay.
If the browser says that it is waiting for the VS Code host or the recent-session list is
empty, verify that `codexRemoteCollab.localRelayUrl` uses the same port as the relay and run
**Developer: Reload Window**. Keep `codexRemoteCollab.threadId` empty unless a specific
conversation must be pinned; an old closed ID can prevent startup until it is cleared.
When authentication is enabled, run **Codex Remote: Set Relay Token** with the
host token. It is stored in `vscode.SecretStorage`, not in settings; the browser
uses the operator or viewer token separately.
With no configured thread ID, the bridge ranks recent VS Code rollout metadata
and shows only candidates verified by live IPC owner discovery and a matching
follower snapshot. Explicit Codex Desktop tasks, closed, stale, and other
non-attachable history entries are omitted.
In synchronous mode, switching the conversation in the official Codex panel
also switches the Web projection after the new owner snapshot is ready. The
Web UI cannot list, select, or create conversations in this mode. Switch to
asynchronous mode when the browser should own conversation navigation.
To avoid ambiguity when several Codex windows are open, run **Codex Remote: Set Existing Thread ID**.
An empty value restores automatic discovery.
Useful commands:
- **Codex Remote: Start Bridge** / **Stop Bridge**
- **Codex Remote: Set Existing Thread ID**
- **Codex Remote: Set Relay Token**
- **Codex Remote: Pair with Aether**
- **Codex Remote: Configure Aether Cloud Relay**
- **Codex Remote: Send Input**
- **Codex Remote: Show Snapshot**
## Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| `codexRemoteCollab.controlMode` | `sync` | `sync` follows VS Code; `async` owns an independent app-server. |
| `codexRemoteCollab.localRelayUrl` | `ws://127.0.0.1:8787/v1/connect` | Bundled loopback relay used by the local Web control. |
| `codexRemoteCollab.aetherUrl` | empty | Aether origin remembered by the pairing command. |
| `codexRemoteCollab.cloudRelayUrl` | empty | Aether WebSocket relay URL populated by pairing. |
| `codexRemoteCollab.threadId` | empty | Exact existing conversation ID; empty enables discovery. |
| `codexRemoteCollab.autoDiscoverThread` | `true` | Discover and owner-check a local VS Code session. |
| `codexRemoteCollab.followVscodeSession` | `true` | Legacy compatibility setting; synchronous mode always follows VS Code. |
| `codexRemoteCollab.ipcSocketPath` | empty | Override the local IPC socket path. |
| `codexRemoteCollab.hostId` | `local` | Owner-discovery host identifier. |
| `codexRemoteCollab.ipcStrictVersions` | `true` | Reject unsupported stream protocol versions. |
| `codexRemoteCollab.approvalTimeoutMs` | `300000` | Deny an unanswered request locally after this delay. |
| `codexRemoteCollab.allowHighRiskApprovals` | `false` | Permit remote high-risk approvals when explicitly enabled. |
`codexRemoteCollab.codexCommand`, `codexArgs`, and `defaultCwd` apply only to
asynchronous mode. The deprecated `mode=attach/spawn` values map to
`controlMode=sync/async` when no explicit control mode exists.
## Pair with Aether
The local relay stays enabled after cloud pairing. In Aether, open **Codex remote
control** and generate a one-time code. Then run **Codex Remote: Pair with Aether**
from the VS Code Command Palette, enter the Aether server URL and the code, and
the bridge will connect to both relays. The long-lived device credential is stored
only in VS Code SecretStorage. Revoke a lost or retired device from the Aether page.
## Relay behavior
The bridge sends a `hello` and, when a relay token is configured, a separate
bearer-auth frame over an outbound WebSocket. It publishes normalized events including:
- `connection.opened` / `connection.closed`
- `session.snapshot`
- `output.snapshot` / `output.chunk`
- `task.started` / `task.finished` / `task.cancelled`
- `approval.requested` / `approval.resolved` / `approval.expired`
- `input.requested` / `input.resolved` / `input.expired`
Remote commands are mapped to the existing conversation owner:
- `control/mode/set` atomically switches between `sync` and `async`.
- `session/list`, `session/select`, and `session/new` are available only in
asynchronous mode and map to `thread/list`, `thread/resume`, and `thread/start`.
- `turn/start` starts a turn in the attached thread.
- `turn/steer` adds input to the active turn.
- `turn/interrupt` interrupts the expected active turn.
- `approval.respond`, `input.respond`, and `server.request.respond` preserve the
original request ID and use method-specific follower responses.
- `thread/start` is deliberately rejected in synchronous mode because VS Code
owns conversation navigation there.
The browser never connects directly to the IPC socket. Relay and host both
enforce role/capability checks; high-risk command approval remains disabled
unless the local VS Code setting opts in.
## Supported follower requests
- `item/commandExecution/requestApproval`
- `item/fileChange/requestApproval`
- `item/permissions/requestApproval`
- `item/tool/requestUserInput`
- `mcpServer/elicitation/request`
- legacy `applyPatchApproval` and `execCommandApproval`
Unanswered requests expire with a local deny. JSON-RPC numeric and string IDs
remain distinct, and a response can be submitted only once.
## Legacy mode migration
The old setting remains accepted:
```json
{
"codexRemoteCollab.mode": "spawn",
"codexRemoteCollab.codexCommand": "/absolute/path/to/codex",
"codexRemoteCollab.codexArgs": ["app-server", "--stdio"]
}
```
It maps to `controlMode=async`. Prefer the new setting directly. A
`spawn codex ENOENT` error belongs only to asynchronous mode; it is not a
synchronous-mode prerequisite or a PATH problem that needs fixing for
existing-session control.
The standalone `npm run start:stdio` entry point and `createBridge()` helper
also retain the legacy app-server adapter for compatibility.
## Embedding the attach adapter
The reusable exports are in `src/index.ts`:
```ts
import {
CodexIpcAgentAdapter,
RelayClient,
RelayHost,
} from "codex-remote-collab";
const adapter = new CodexIpcAgentAdapter({
threadId: process.env.CODEX_THREAD_ID,
autoDiscoverThread: true,
});
const relay = new RelayClient({
url: "wss://relay.example.test/v1/connect",
accessToken: process.env.CODEX_REMOTE_HOST_TOKEN,
});
const host = new RelayHost({ adapter, relay });
await host.start();
```
`CodexIpcClient` is exported separately for protocol fixtures and diagnostics.
Use `followConversation()` before follower mutations, and always target the
owner returned by `findThreadOwner()`.
## Troubleshooting
- **No existing session found:** open the target official Codex conversation,
keep that VS Code window running, then retry or set its exact thread ID.
- **Owner not found:** the rollout exists on disk but no live official client
currently owns it. Reopen the conversation in the Codex panel.
- **IPC version mismatch:** update this bridge for the installed official
extension. Disabling strict versions is diagnostic only.
- **Relay stays at waiting for host:** confirm host mode, relay URL, and that no
second host is already connected. If authentication is enabled, also check the
host token.
- **Old `spawn codex ENOENT` message:** install version `0.4.0`, reload VS Code,
and verify `codexRemoteCollab.controlMode` is `sync` unless independent
conversations are intended.
@@ -0,0 +1,56 @@
{
"A non-empty Aether device credential is required.": "A non-empty Aether device credential is required.",
"Aether cloud connection removed. Local control remains enabled.": "Aether cloud connection removed. Local control remains enabled.",
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.",
"Aether cloud relay WebSocket URL": "Aether cloud relay WebSocket URL",
"Aether pairing completed. Local and cloud control are both active.": "Aether pairing completed. Local and cloud control are both active.",
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.",
"Aether returned an invalid pairing response.": "Aether returned an invalid pairing response.",
"Aether server URL": "Aether server URL",
"Attached to the existing Codex conversation. Click to open the web control.": "Attached to the existing Codex conversation. Click to open the web control.",
"Bridge connected. Click to open the web control.": "Bridge connected. Click to open the web control.",
"Bridge paused. Click to open the web control and resume automatically.": "Bridge paused. Click to open the web control and resume automatically.",
"Codex Remote Collaboration": "Codex Remote Collaboration",
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote will attach to {0} after the next bridge start.",
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.",
"Connecting to the local Codex collaboration service": "Connecting to the local Codex collaboration service",
"Device credential from the Aether pairing flow": "Device credential from the Aether pairing flow",
"Enter a valid URL.": "Enter a valid URL.",
"Enter a valid WebSocket URL.": "Enter a valid WebSocket URL.",
"Enter the 8-character pairing code.": "Enter the 8-character pairing code.",
"Enter the Aether server URL.": "Enter the Aether server URL.",
"Existing Codex conversation ID (leave blank for auto-discovery)": "Existing Codex conversation ID (leave blank for auto-discovery)",
"Independent Codex mode is connected. Click to open the web control.": "Independent Codex mode is connected. Click to open the web control.",
"One-time pairing code shown in Aether": "One-time pairing code shown in Aether",
"Relay access token (leave blank for the local relay)": "Relay access token (leave blank for the local relay)",
"Relay token stored in VS Code SecretStorage.": "Relay token stored in VS Code SecretStorage.",
"Remote Aether connections must use wss://.": "Remote Aether connections must use wss://.",
"Remote Aether servers must use https://.": "Remote Aether servers must use https://.",
"Restoring the local collaboration service": "Restoring the local collaboration service",
"Send input to the active Codex turn": "Send input to the active Codex turn",
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "Set codexRemoteCollab.localRelayUrl before starting the bridge.",
"Start the Codex remote bridge first.": "Start the Codex remote bridge first.",
"Starting the independent Codex mode.": "Starting the independent Codex mode.",
"Starting {0}": "Starting {0}",
"The Codex conversation is not connected": "The Codex conversation is not connected",
"The Codex executable is unavailable": "The Codex executable is unavailable",
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "The Codex remote bridge attached to the existing VS Code Codex conversation.",
"The Codex remote bridge is already running.": "The Codex remote bridge is already running.",
"The Codex remote collaboration bridge connected.": "The Codex remote collaboration bridge connected.",
"The independent Codex mode is not connected": "The independent Codex mode is not connected",
"The independent Codex remote mode connected.": "The independent Codex remote mode connected.",
"The bridge is not connected": "The bridge is not connected",
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.",
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.",
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.",
"Unable to pair with Aether: {0}": "Unable to pair with Aether: {0}",
"Unable to restore the local collaboration service": "Unable to restore the local collaboration service",
"Unable to send Codex input: {0}": "Unable to send Codex input: {0}",
"Unable to start the Codex remote bridge: {0}": "Unable to start the Codex remote bridge: {0}",
"Unable to start the local Codex collaboration service: {0}": "Unable to start the local Codex collaboration service: {0}",
"Unable to start the local collaboration service: {0}": "Unable to start the local collaboration service: {0}",
"Use a ws:// or wss:// URL.": "Use a ws:// or wss:// URL.",
"Use the Aether origin without credentials, a query, or a fragment.": "Use the Aether origin without credentials, a query, or a fragment.",
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "Waiting for a Codex conversation to open in VS Code. It will connect automatically.",
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl must be a loopback ws:// address."
}
@@ -0,0 +1,56 @@
{
"A non-empty Aether device credential is required.": "必须填写 Aether 设备凭据。",
"Aether cloud connection removed. Local control remains enabled.": "已移除 Aether 云端连接,本地控制仍然可用。",
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "已保存 Aether 云端连接。重启 Codex Remote 桥接后即可连接,本地控制仍然可用。",
"Aether cloud relay WebSocket URL": "Aether 云端 relay WebSocket 地址",
"Aether pairing completed. Local and cloud control are both active.": "Aether 配对完成,本地与云端控制均已启用。",
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether 配对信息已保存,但当前无法连接云端。本地控制仍然可用,云端连接会继续重试。",
"Aether returned an invalid pairing response.": "Aether 返回了无效的配对响应。",
"Aether server URL": "Aether 服务器地址",
"Attached to the existing Codex conversation. Click to open the web control.": "已附加到现有 Codex 会话,点击打开 Web 控制页。",
"Bridge connected. Click to open the web control.": "桥接已连接,点击打开 Web 控制页。",
"Bridge paused. Click to open the web control and resume automatically.": "桥接已暂停,点击打开 Web 控制页时会自动恢复。",
"Codex Remote Collaboration": "Codex 远程协同",
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote 将在下次启动桥接后附加到 {0}。",
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote 将在下次启动桥接后自动发现最新的 VS Code Codex 会话。",
"Connecting to the local Codex collaboration service": "正在连接本地 Codex 协同服务",
"Device credential from the Aether pairing flow": "Aether 配对流程生成的设备凭据",
"Enter a valid URL.": "请输入有效的 URL。",
"Enter a valid WebSocket URL.": "请输入有效的 WebSocket URL。",
"Enter the 8-character pairing code.": "请输入 8 位配对码。",
"Enter the Aether server URL.": "请输入 Aether 服务器地址。",
"Existing Codex conversation ID (leave blank for auto-discovery)": "现有 Codex 会话 ID(留空则自动发现)",
"Independent Codex mode is connected. Click to open the web control.": "独立 Codex 模式已连接,点击打开 Web 控制页。",
"One-time pairing code shown in Aether": "Aether 中显示的一次性配对码",
"Relay access token (leave blank for the local relay)": "Relay 访问 token(本地 relay 请留空)",
"Relay token stored in VS Code SecretStorage.": "Relay token 已保存到 VS Code SecretStorage。",
"Remote Aether connections must use wss://.": "远程 Aether 连接必须使用 wss://。",
"Remote Aether servers must use https://.": "远程 Aether 服务器必须使用 https://。",
"Restoring the local collaboration service": "正在恢复本地协同服务",
"Send input to the active Codex turn": "向当前 Codex turn 发送输入",
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "请先设置 codexRemoteCollab.localRelayUrl,再启动桥接。",
"Start the Codex remote bridge first.": "请先启动 Codex 远程桥接。",
"Starting the independent Codex mode.": "正在启动独立 Codex 模式。",
"Starting {0}": "正在启动 {0}",
"The Codex conversation is not connected": "Codex 会话尚未连接",
"The Codex executable is unavailable": "Codex 可执行文件不可用",
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "Codex 远程桥接已附加到现有 VS Code Codex 会话。",
"The Codex remote bridge is already running.": "Codex 远程桥接已在运行。",
"The Codex remote collaboration bridge connected.": "Codex 远程协同桥接已连接。",
"The independent Codex mode is not connected": "独立 Codex 模式尚未连接",
"The independent Codex remote mode connected.": "独立 Codex 远程模式已连接。",
"The bridge is not connected": "桥接尚未连接",
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "本地协同地址无效,请检查 codexRemoteCollab.localRelayUrl。",
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "本地协同服务 {0} 暂时无法连接,扩展会继续重试。",
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "未找到官方 Codex 扩展的新会话命令,请确认 VS Code Codex 扩展已启用。",
"Unable to pair with Aether: {0}": "无法与 Aether 配对:{0}",
"Unable to restore the local collaboration service": "无法恢复本地协同服务",
"Unable to send Codex input: {0}": "无法发送 Codex 输入:{0}",
"Unable to start the Codex remote bridge: {0}": "无法启动 Codex 远程桥接:{0}",
"Unable to start the local Codex collaboration service: {0}": "无法启动本地 Codex 协同服务:{0}",
"Unable to start the local collaboration service: {0}": "无法启动本地协同服务:{0}",
"Use a ws:// or wss:// URL.": "请使用 ws:// 或 wss:// URL。",
"Use the Aether origin without credentials, a query, or a fragment.": "请填写不含凭据、查询参数或片段的 Aether 源地址。",
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "正在等待 VS Code 中打开 Codex 会话,检测到后会自动连接。",
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl 必须是回环地址上的 ws:// URL。"
}
+94
View File
@@ -0,0 +1,94 @@
{
"name": "codex-remote-collab",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-remote-collab",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/vscode": "^1.85.0",
"@types/ws": "^8.5.12",
"typescript": "^5.4.5"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.134.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz",
"integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
@@ -0,0 +1,211 @@
{
"name": "codex-remote-collab",
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"version": "0.4.0",
"publisher": "local",
"license": "MIT",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Other"
],
"l10n": "./l10n",
"activationEvents": [
"onStartupFinished",
"onCommand:codexRemoteCollab.openWeb",
"onCommand:codexRemoteCollab.start",
"onCommand:codexRemoteCollab.stop",
"onCommand:codexRemoteCollab.setThreadId",
"onCommand:codexRemoteCollab.sendInput",
"onCommand:codexRemoteCollab.setRelayToken",
"onCommand:codexRemoteCollab.configureCloud",
"onCommand:codexRemoteCollab.pairCloud",
"onCommand:codexRemoteCollab.snapshot"
],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{
"command": "codexRemoteCollab.openWeb",
"title": "%command.openWeb%"
},
{
"command": "codexRemoteCollab.start",
"title": "%command.start%"
},
{
"command": "codexRemoteCollab.stop",
"title": "%command.stop%"
},
{
"command": "codexRemoteCollab.setThreadId",
"title": "%command.setThreadId%"
},
{
"command": "codexRemoteCollab.sendInput",
"title": "%command.sendInput%"
},
{
"command": "codexRemoteCollab.setRelayToken",
"title": "%command.setRelayToken%"
},
{
"command": "codexRemoteCollab.configureCloud",
"title": "%command.configureCloud%"
},
{
"command": "codexRemoteCollab.pairCloud",
"title": "%command.pairCloud%"
},
{
"command": "codexRemoteCollab.snapshot",
"title": "%command.snapshot%"
}
],
"configuration": {
"title": "%configuration.title%",
"properties": {
"codexRemoteCollab.localRelayUrl": {
"type": "string",
"default": "ws://127.0.0.1:8787/v1/connect",
"description": "%configuration.localRelayUrl%"
},
"codexRemoteCollab.relayUrl": {
"type": "string",
"default": "ws://127.0.0.1:8787/v1/connect",
"description": "%configuration.relayUrl%",
"deprecationMessage": "%configuration.relayUrl.deprecation%"
},
"codexRemoteCollab.cloudRelayUrl": {
"type": "string",
"default": "",
"description": "%configuration.cloudRelayUrl%"
},
"codexRemoteCollab.aetherUrl": {
"type": "string",
"default": "",
"description": "%configuration.aetherUrl%"
},
"codexRemoteCollab.autoStart": {
"type": "boolean",
"default": true,
"description": "%configuration.autoStart%"
},
"codexRemoteCollab.autoStartLocalRelay": {
"type": "boolean",
"default": true,
"description": "%configuration.autoStartLocalRelay%"
},
"codexRemoteCollab.mode": {
"type": "string",
"enum": [
"attach",
"spawn"
],
"default": "attach",
"description": "%configuration.mode%",
"deprecationMessage": "%configuration.mode.deprecation%"
},
"codexRemoteCollab.controlMode": {
"type": "string",
"enum": [
"sync",
"async"
],
"enumDescriptions": [
"%configuration.controlMode.sync%",
"%configuration.controlMode.async%"
],
"default": "sync",
"description": "%configuration.controlMode%"
},
"codexRemoteCollab.threadId": {
"type": "string",
"default": "",
"description": "%configuration.threadId%"
},
"codexRemoteCollab.autoDiscoverThread": {
"type": "boolean",
"default": true,
"description": "%configuration.autoDiscoverThread%"
},
"codexRemoteCollab.followVscodeSession": {
"type": "boolean",
"default": true,
"description": "%configuration.followVscodeSession%"
},
"codexRemoteCollab.ipcSocketPath": {
"type": "string",
"default": "",
"description": "%configuration.ipcSocketPath%"
},
"codexRemoteCollab.hostId": {
"type": "string",
"default": "local",
"description": "%configuration.hostId%"
},
"codexRemoteCollab.ipcStrictVersions": {
"type": "boolean",
"default": true,
"description": "%configuration.ipcStrictVersions%"
},
"codexRemoteCollab.codexCommand": {
"type": "string",
"default": "codex",
"description": "%configuration.codexCommand%"
},
"codexRemoteCollab.codexArgs": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"app-server",
"--stdio"
],
"description": "%configuration.codexArgs%"
},
"codexRemoteCollab.defaultCwd": {
"type": "string",
"default": "",
"description": "%configuration.defaultCwd%"
},
"codexRemoteCollab.approvalTimeoutMs": {
"type": "number",
"default": 300000,
"minimum": 1000,
"description": "%configuration.approvalTimeoutMs%"
},
"codexRemoteCollab.allowHighRiskApprovals": {
"type": "boolean",
"default": false,
"description": "%configuration.allowHighRiskApprovals%"
},
"codexRemoteCollab.relayReconnect": {
"type": "boolean",
"default": true,
"description": "%configuration.relayReconnect%"
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run build:web && npm run build",
"build:web": "npm --prefix ../web run build",
"build": "tsc -p tsconfig.json && node scripts/sync-local-relay.cjs",
"compile": "npm run build",
"check": "tsc --noEmit -p tsconfig.json",
"start:stdio": "node dist/cli.js"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/vscode": "^1.85.0",
"@types/ws": "^8.5.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,38 @@
{
"extension.displayName": "Codex Remote Collaboration",
"extension.description": "Synchronize the current VS Code Codex conversation or manage independent Codex conversations from a local browser and Aether cloud.",
"command.openWeb": "Codex Remote: Open Local Web Console",
"command.start": "Codex Remote: Start Bridge",
"command.stop": "Codex Remote: Stop Bridge",
"command.setThreadId": "Codex Remote: Set Existing Thread ID",
"command.sendInput": "Codex Remote: Send Input",
"command.setRelayToken": "Codex Remote: Set Local Relay Token",
"command.configureCloud": "Codex Remote: Configure Aether Cloud Manually",
"command.pairCloud": "Codex Remote: Pair with Aether",
"command.snapshot": "Codex Remote: Show Snapshot",
"configuration.title": "Codex Remote Collaboration",
"configuration.localRelayUrl": "Loopback relay used by the local browser UI. It remains active when Aether cloud sync is enabled.",
"configuration.relayUrl": "Legacy relay setting retained for compatibility. Use localRelayUrl and cloudRelayUrl for new installations.",
"configuration.relayUrl.deprecation": "Use codexRemoteCollab.localRelayUrl for local access and codexRemoteCollab.cloudRelayUrl for Aether cloud access.",
"configuration.cloudRelayUrl": "Optional Aether cloud relay WebSocket URL. The device credential is stored separately in VS Code SecretStorage.",
"configuration.aetherUrl": "Aether server origin used by the one-time pairing flow.",
"configuration.autoStart": "Start the bridge when the extension activates.",
"configuration.autoStartLocalRelay": "Automatically host the bundled relay for loopback ws:// URLs.",
"configuration.mode": "Attach to the existing official VS Code Codex session, or spawn a separate app-server for legacy use.",
"configuration.mode.deprecation": "Use codexRemoteCollab.controlMode. attach maps to sync and spawn maps to async.",
"configuration.controlMode": "Choose whether the web console follows the current VS Code Codex conversation or manages independent conversations.",
"configuration.controlMode.sync": "Synchronize with the conversation currently shown in the official VS Code Codex panel.",
"configuration.controlMode.async": "Run an independent Codex app-server and manage its conversations from the web console.",
"configuration.threadId": "Existing VS Code Codex conversation ID to follow. Empty uses the most recent locally available session.",
"configuration.autoDiscoverThread": "Discover a recent VS Code Codex conversation when no thread ID is configured.",
"configuration.followVscodeSession": "Follow conversation changes in the attached official VS Code Codex panel.",
"configuration.ipcSocketPath": "Optional official Codex IPC socket path. Empty uses CODEX_HOME/ipc/ipc.sock.",
"configuration.hostId": "Codex host identifier used for existing-session discovery.",
"configuration.ipcStrictVersions": "Reject unknown private IPC stream versions instead of applying them optimistically.",
"configuration.codexCommand": "Asynchronous mode: Codex executable used to launch the independent app-server.",
"configuration.codexArgs": "Asynchronous mode: arguments passed to the Codex executable.",
"configuration.defaultCwd": "Asynchronous mode: working directory used when starting a conversation.",
"configuration.approvalTimeoutMs": "Milliseconds before an unanswered Codex approval or input request is denied locally.",
"configuration.allowHighRiskApprovals": "Allow the remote operator to approve high-risk commands. Keep disabled unless the relay and host are tightly controlled.",
"configuration.relayReconnect": "Reconnect outbound relay WebSockets after a disconnect."
}
@@ -0,0 +1,38 @@
{
"extension.displayName": "Codex 远程协同",
"extension.description": "从本地浏览器或 Aether 云端同步 VS Code 当前 Codex 会话,或独立管理 Codex 会话。",
"command.openWeb": "Codex 远程:打开本地 Web 控制台",
"command.start": "Codex 远程:启动桥接",
"command.stop": "Codex 远程:停止桥接",
"command.setThreadId": "Codex 远程:设置现有会话 ID",
"command.sendInput": "Codex 远程:发送输入",
"command.setRelayToken": "Codex 远程:设置本地中继令牌",
"command.configureCloud": "Codex 远程:手动配置 Aether 云端",
"command.pairCloud": "Codex 远程:与 Aether 配对",
"command.snapshot": "Codex 远程:显示会话快照",
"configuration.title": "Codex 远程协同",
"configuration.localRelayUrl": "本地浏览器控制台使用的回环中继地址。启用 Aether 云同步后仍保持连接。",
"configuration.relayUrl": "为兼容旧版本保留的中继设置。新安装请使用 localRelayUrl 和 cloudRelayUrl。",
"configuration.relayUrl.deprecation": "本地访问请使用 codexRemoteCollab.localRelayUrl,Aether 云端访问请使用 codexRemoteCollab.cloudRelayUrl。",
"configuration.cloudRelayUrl": "可选的 Aether 云端 WebSocket 中继地址。设备凭据单独保存在 VS Code SecretStorage 中。",
"configuration.aetherUrl": "一次性配对流程使用的 Aether 服务地址。",
"configuration.autoStart": "扩展激活时自动启动桥接。",
"configuration.autoStartLocalRelay": "为回环 ws:// 地址自动启动扩展内置的本地中继。",
"configuration.mode": "附加到官方 VS Code Codex 现有会话,或为兼容旧版本启动独立 app-server。",
"configuration.mode.deprecation": "请改用 codexRemoteCollab.controlMode。attach 对应 sync,spawn 对应 async。",
"configuration.controlMode": "选择 Web 控制台是跟随 VS Code 当前 Codex 会话,还是独立管理会话。",
"configuration.controlMode.sync": "同步展示官方 VS Code Codex 面板当前打开的会话。",
"configuration.controlMode.async": "启动独立 Codex app-server,并从 Web 控制台管理其会话。",
"configuration.threadId": "要跟随的现有 VS Code Codex 会话 ID。留空时使用本机最近可附加的会话。",
"configuration.autoDiscoverThread": "未设置会话 ID 时自动发现最近的 VS Code Codex 会话。",
"configuration.followVscodeSession": "自动跟随官方 VS Code Codex 面板中的会话切换。",
"configuration.ipcSocketPath": "可选的官方 Codex IPC socket 路径。留空时使用 CODEX_HOME/ipc/ipc.sock。",
"configuration.hostId": "现有会话发现使用的 Codex 主机标识。",
"configuration.ipcStrictVersions": "拒绝未知的私有 IPC 流版本,不进行乐观兼容。",
"configuration.codexCommand": "异步模式:用于启动独立 app-server 的 Codex 可执行文件。",
"configuration.codexArgs": "异步模式:传给 Codex 可执行文件的参数。",
"configuration.defaultCwd": "异步模式:启动会话时使用的工作目录。",
"configuration.approvalTimeoutMs": "Codex 授权或输入请求无人处理时,在本地拒绝前等待的毫秒数。",
"configuration.allowHighRiskApprovals": "允许远程操作员批准高风险命令。仅在中继和主机均受严格控制时启用。",
"configuration.relayReconnect": "中继 WebSocket 断开后自动重连。"
}
@@ -0,0 +1,19 @@
const fs = require("node:fs");
const path = require("node:path");
const extensionRoot = path.resolve(__dirname, "..");
const projectRoot = path.resolve(extensionRoot, "..");
const outputRoot = path.join(extensionRoot, "dist", "local-relay");
const publicRoot = path.join(extensionRoot, "dist", "public");
const vuePublicRoot = path.join(projectRoot, "web", "dist");
if (!fs.existsSync(path.join(vuePublicRoot, "index.html"))) {
throw new Error("web/dist is missing; run npm run build:web before building the extension");
}
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.rmSync(publicRoot, { recursive: true, force: true });
fs.mkdirSync(outputRoot, { recursive: true });
fs.mkdirSync(publicRoot, { recursive: true });
fs.copyFileSync(path.join(projectRoot, "relay", "server.js"), path.join(outputRoot, "server.js"));
fs.cpSync(vuePublicRoot, publicRoot, { recursive: true });
@@ -0,0 +1,46 @@
import { CodexAgentAdapter, CodexAgentAdapterOptions } from "./codexAgentAdapter";
import { RelayClient, RelayClientOptions } from "./relayClient";
import { RelayHost, RelayHostOptions } from "./relayHost";
import { AgentAdapter, Logger, RelayTransport } from "./protocol";
export interface CodexRemoteBridgeOptions {
/** Use a supplied adapter/transport when embedding or testing. */
adapter?: AgentAdapter;
relay?: RelayTransport;
adapterOptions?: CodexAgentAdapterOptions;
relayOptions?: RelayClientOptions;
sessionId?: string;
capabilities?: Iterable<string>;
logger?: Logger;
}
export interface CodexRemoteBridge {
adapter: AgentAdapter;
relay: RelayTransport;
host: RelayHost;
start(): Promise<void>;
stop(): Promise<void>;
}
/** Construct the default outbound VS Code bridge in one call. */
export function createBridge(options: CodexRemoteBridgeOptions): CodexRemoteBridge {
const adapter = options.adapter ?? new CodexAgentAdapter(options.adapterOptions);
const relay = options.relay ?? (() => {
if (!options.relayOptions) throw new Error("relayOptions are required when no relay transport is supplied");
return new RelayClient(options.relayOptions);
})();
const hostOptions: RelayHostOptions = {
adapter,
relay,
...(options.sessionId ? { sessionId: options.sessionId } : {}),
...(options.capabilities ? { capabilities: options.capabilities } : {}),
...(options.logger ? { logger: options.logger } : {}),
};
const host = new RelayHost(hostOptions);
return {
adapter,
relay,
host,
start: () => host.start(),
stop: () => host.stop(),
};
}
@@ -0,0 +1,30 @@
import { CodexAgentAdapter } from "./codexAgentAdapter";
import { RelayHost } from "./relayHost";
import { StdioRelayTransport } from "./relayClient";
/** Standalone bridge: relay frames in stdin, relay frames out on stdout. */
async function main(): Promise<void> {
const logger = {
debug: (message: string, ...args: unknown[]) => console.error(`[debug] ${message}`, ...args),
info: (message: string, ...args: unknown[]) => console.error(`[info] ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => console.error(`[warn] ${message}`, ...args),
error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
};
const command = process.env.CODEX_COMMAND || "codex";
const args = process.env.CODEX_APP_SERVER_ARGS ? JSON.parse(process.env.CODEX_APP_SERVER_ARGS) as string[] : ["app-server", "--stdio"];
const adapter = new CodexAgentAdapter({ command, args, defaultCwd: process.env.CODEX_WORKSPACE, logger });
const relay = new StdioRelayTransport(process.stdin, process.stdout, logger);
const host = new RelayHost({ adapter, relay, sendHandshake: true, logger });
const shutdown = async (): Promise<void> => {
await host.stop();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
await host.start();
}
void main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exitCode = 1;
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,947 @@
/**
* Minimal client for the private Codex desktop/VS Code coordination socket.
*
* This is intentionally separate from the app-server (JSONL/stdio) adapter.
* It attaches to the already running Codex UI through the local IPC router and
* therefore does not spawn another `codex` process. The wire protocol is
* private and versioned by the official extension; keep this module isolated
* so a protocol change can fail without taking down the relay bridge.
*/
import * as crypto from "node:crypto";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import type { JsonObject, JsonValue } from "./protocol";
export const INITIALIZING_CLIENT_ID = "initializing-client";
export const DEFAULT_IPC_REQUEST_TIMEOUT_MS = 5_000;
export const DEFAULT_MAX_IPC_FRAME_BYTES = 256 * 1024 * 1024;
/** Versions shipped by openai.chatgpt 26.820.71523. */
export const CODEX_IPC_METHOD_VERSIONS = Object.freeze({
"thread-stream-state-changed": 11,
"thread-stream-following-changed": 1,
"thread-stream-following-status-requested": 1,
"ipc-connection-reset": 1,
"thread-read-state-changed": 2,
"thread-archived": 2,
"thread-unarchived": 1,
"thread-owner-discovery": 1,
"thread-follower-start-turn": 2,
"thread-follower-load-complete-history": 1,
"thread-follower-compact-thread": 1,
"thread-follower-steer-turn": 1,
"thread-follower-interrupt-turn": 4,
"thread-follower-update-thread-settings": 1,
"thread-follower-edit-last-user-turn": 2,
"thread-follower-command-approval-decision": 1,
"thread-follower-file-approval-decision": 1,
"thread-follower-permissions-request-approval-response": 1,
"thread-follower-submit-user-input": 1,
"thread-follower-submit-mcp-server-elicitation-response": 1,
"thread-follower-set-queued-follow-ups-state": 1,
"thread-queued-followups-changed": 1,
} as const);
export type IpcMethod = keyof typeof CODEX_IPC_METHOD_VERSIONS;
export type IpcRequestId = string | number;
export type IpcPatchPathPart = string | number;
export interface IpcRequest {
type: "request";
requestId: IpcRequestId;
sourceClientId: string;
targetClientId?: string;
version: number;
method: string;
params?: JsonValue;
timeoutMs?: number;
}
export interface IpcResponse {
type: "response";
requestId: IpcRequestId;
resultType: "success" | "error";
method?: string;
handledByClientId?: string;
result?: JsonValue;
error?: string;
}
export interface IpcBroadcast {
type: "broadcast";
method: string;
sourceClientId?: string;
targetClientIds?: string[];
version: number;
params?: JsonValue;
}
export interface IpcClientDiscoveryRequest {
type: "client-discovery-request";
requestId: IpcRequestId;
request: IpcRequest;
}
export interface IpcClientDiscoveryResponse {
type: "client-discovery-response";
requestId: IpcRequestId;
response: { canHandle: boolean };
}
export type IpcMessage =
| IpcRequest
| IpcResponse
| IpcBroadcast
| IpcClientDiscoveryRequest
| IpcClientDiscoveryResponse;
export interface IpcJsonPatch {
op: "add" | "remove" | "replace";
path: IpcPatchPathPart[];
value?: JsonValue;
}
export interface ThreadStreamSnapshot {
type: "snapshot";
revision: number;
conversationState: JsonObject;
}
export interface ThreadStreamPatches {
type: "patches";
baseRevision: number;
revision: number;
patches: IpcJsonPatch[];
}
export type ThreadStreamChange = ThreadStreamSnapshot | ThreadStreamPatches;
export interface ConversationStreamState {
conversationId: string;
hostId: string;
ownerClientId: string;
revision: number;
conversationState: JsonObject;
}
export type ConversationStreamEvent =
| (ConversationStreamState & { kind: "snapshot"; raw: IpcBroadcast })
| (ConversationStreamState & { kind: "patches"; patches: IpcJsonPatch[]; baseRevision: number; raw: IpcBroadcast })
| {
kind: "desync";
conversationId: string;
hostId: string;
ownerClientId: string;
expectedRevision: number;
receivedBaseRevision: number;
receivedRevision: number;
raw: IpcBroadcast;
};
export interface CodexIpcClientOptions {
/** Explicit socket path; otherwise `$CODEX_HOME/ipc/ipc.sock` or `~/.codex`. */
socketPath?: string;
codexHome?: string;
homeDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
clientType?: string;
requestTimeoutMs?: number;
maxFrameBytes?: number;
strictVersions?: boolean;
/** Reconnect after a socket close and re-send all active following subscriptions. */
autoReconnect?: boolean;
reconnectDelayMs?: number;
/** Optional handler for discovery requests. Default is fail-closed (`false`). */
canHandleRequest?: (request: IpcRequest) => boolean | Promise<boolean>;
}
export interface FollowerTurnStartOptions {
request?: JsonObject;
context?: JsonObject;
clientUserMessageId?: string;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowerSteerOptions {
clientUserMessageId?: string;
serviceTier?: string | null;
attachments?: JsonValue[];
additionalContext?: JsonObject | null;
restoreMessage?: JsonValue | null;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowerInterruptOptions {
mode?: "user-stop" | "system" | "descendant-cleanup" | string;
expectedTurnId?: string | null;
ownerClientId?: string;
timeoutMs?: number;
}
export interface FollowOptions {
hostId?: string;
targetClientIds?: string[];
}
export interface RequestOptions {
targetClientId?: string;
timeoutMs?: number;
version?: number;
requestId?: IpcRequestId;
}
export interface IpcErrorOptions {
code: string;
response?: IpcResponse;
}
export class CodexIpcError extends Error {
readonly code: string;
readonly response?: IpcResponse;
constructor(message: string, options: IpcErrorOptions) {
super(message);
this.name = "CodexIpcError";
this.code = options.code;
this.response = options.response;
}
}
export function resolveCodexIpcSocketPath(options: {
socketPath?: string;
codexHome?: string;
homeDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
} = {}): string {
if (options.socketPath?.trim()) return options.socketPath.trim();
const platform = options.platform ?? process.platform;
if (platform === "win32") return "\\\\.\\pipe\\codex-ipc";
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const configuredHome = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(homeDir, ".codex");
const codexHome = configuredHome === "~"
? homeDir
: configuredHome.startsWith("~/")
? path.join(homeDir, configuredHome.slice(2))
: configuredHome;
return path.join(codexHome, "ipc", "ipc.sock");
}
/** Encode one private IPC frame: uint32 little-endian byte length + UTF-8 JSON. */
export function encodeIpcFrame(message: IpcMessage, maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES): Buffer {
const json = JSON.stringify(message);
const payload = Buffer.from(json, "utf8");
if (payload.length === 0 || payload.length > maxFrameBytes) {
throw new RangeError(`IPC frame exceeds ${maxFrameBytes} bytes`);
}
const frame = Buffer.allocUnsafe(4 + payload.length);
frame.writeUInt32LE(payload.length, 0);
payload.copy(frame, 4);
return frame;
}
/** Incremental decoder that accepts arbitrary TCP/Unix-socket chunk boundaries. */
export class IpcFrameDecoder {
private buffer = Buffer.alloc(0);
constructor(private readonly maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES) {}
push(chunk: Uint8Array): IpcMessage[] {
if (chunk.length === 0) return [];
this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
const messages: IpcMessage[] = [];
while (this.buffer.length >= 4) {
const payloadLength = this.buffer.readUInt32LE(0);
if (payloadLength === 0 || payloadLength > this.maxFrameBytes) {
throw new CodexIpcError(`Invalid IPC frame length (${payloadLength} bytes)`, { code: "invalid-frame-length" });
}
if (this.buffer.length < payloadLength + 4) break;
const payload = this.buffer.subarray(4, payloadLength + 4).toString("utf8");
this.buffer = this.buffer.subarray(payloadLength + 4);
let decoded: unknown;
try {
decoded = JSON.parse(payload);
} catch (error) {
throw new CodexIpcError(`Invalid IPC JSON: ${error instanceof Error ? error.message : String(error)}`, {
code: "invalid-json",
});
}
if (!isRecord(decoded) || typeof decoded.type !== "string") {
throw new CodexIpcError("IPC frame must be an object with a type", { code: "invalid-message" });
}
messages.push(decoded as unknown as IpcMessage);
}
return messages;
}
reset(): void {
this.buffer = Buffer.alloc(0);
}
}
type Listener<T> = (value: T) => void;
export interface IpcSubscription { dispose(): void; }
function subscribe<T>(set: Set<Listener<T>>, listener: Listener<T>): IpcSubscription {
set.add(listener);
return { dispose: () => set.delete(listener) };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isJsonObject(value: unknown): value is JsonObject {
return isRecord(value);
}
function requestIdKey(id: IpcRequestId): string {
return `${typeof id}:${String(id)}`;
}
function cloneJson<T extends JsonValue>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function versionFor(method: string, params?: JsonValue): number {
// The official client accepts interrupt v3 when expectedTurnId is absent;
// v4 is used when the active-turn precondition is present.
if (method === "thread-follower-interrupt-turn"
&& (!isRecord(params) || params.expectedTurnId === undefined || params.expectedTurnId === null)) return 3;
return CODEX_IPC_METHOD_VERSIONS[method as IpcMethod] ?? 0;
}
function textInput(text: string): JsonObject {
return { type: "text", text, text_elements: [] };
}
function normalizeInput(input: string | JsonValue[]): JsonValue[] {
return typeof input === "string"
? [textInput(input)]
: input.map((entry) => typeof entry === "string" ? textInput(entry) : entry);
}
function hasTarget(frame: IpcBroadcast, clientId: string): boolean {
return frame.targetClientIds == null || frame.targetClientIds.includes(clientId);
}
/** Apply the JSON patch arrays generated by Immer in the official webview. */
export function applyIpcPatches(root: JsonValue, patches: IpcJsonPatch[]): JsonValue {
let result = cloneJson(root);
for (const patch of patches) {
if (!Array.isArray(patch.path)) throw new CodexIpcError("IPC patch path must be an array", { code: "invalid-patch" });
if (patch.path.length === 0) {
if (patch.op === "remove") throw new CodexIpcError("Removing the conversation root is unsupported", { code: "invalid-patch" });
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
result = cloneJson(patch.value);
continue;
}
const parentPath = patch.path.slice(0, -1);
const key = patch.path[patch.path.length - 1];
assertSafePatchPart(key);
const parent = getAtPath(result, parentPath);
if (Array.isArray(parent)) {
const index = key === "-" ? parent.length : toArrayIndex(key);
if (patch.op === "add") {
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
parent.splice(index, 0, cloneJson(patch.value));
} else if (patch.op === "replace") {
if (patch.value === undefined || index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array replace patch", { code: "invalid-patch" });
parent[index] = cloneJson(patch.value);
} else {
if (index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array remove patch", { code: "invalid-patch" });
parent.splice(index, 1);
}
continue;
}
if (!isRecord(parent) || typeof key !== "string") {
throw new CodexIpcError("IPC patch parent is not an object or array", { code: "invalid-patch" });
}
if (patch.op === "remove") {
delete parent[key];
} else {
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
parent[key] = cloneJson(patch.value);
}
}
return result;
}
function getAtPath(root: JsonValue, pathParts: IpcPatchPathPart[]): JsonValue {
let current: JsonValue = root;
for (const part of pathParts) {
if (Array.isArray(current)) {
const index = toArrayIndex(part);
if (index < 0 || index >= current.length) throw new CodexIpcError("IPC patch path is out of bounds", { code: "invalid-patch" });
current = current[index];
} else if (isRecord(current) && typeof part === "string" && Object.prototype.hasOwnProperty.call(current, part)) {
assertSafePatchPart(part);
current = current[part];
} else {
throw new CodexIpcError("IPC patch path does not exist", { code: "invalid-patch" });
}
}
return current;
}
function toArrayIndex(value: IpcPatchPathPart): number {
if (typeof value === "number" && Number.isInteger(value)) return value;
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
throw new CodexIpcError(`Invalid array patch index: ${String(value)}`, { code: "invalid-patch" });
}
function assertSafePatchPart(value: IpcPatchPathPart): void {
if (value === "__proto__" || value === "prototype" || value === "constructor") {
throw new CodexIpcError("Unsafe IPC patch path", { code: "invalid-patch" });
}
}
export class CodexIpcClient {
readonly socketPath: string;
private readonly options: Required<Pick<CodexIpcClientOptions, "clientType" | "requestTimeoutMs" | "maxFrameBytes" | "strictVersions" | "autoReconnect" | "reconnectDelayMs">> & CodexIpcClientOptions;
private socket: net.Socket | undefined;
private decoder: IpcFrameDecoder;
private connectPromise: Promise<string> | undefined;
private reconnectTimer: NodeJS.Timeout | undefined;
private disposed = false;
private clientId = INITIALIZING_CLIENT_ID;
private readonly pending = new Map<string, { method: string; resolve: (response: IpcResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private readonly followed = new Map<string, string>();
private readonly streams = new Map<string, ConversationStreamState>();
private readonly messageListeners = new Set<Listener<IpcMessage>>();
private readonly broadcastListeners = new Set<Listener<IpcBroadcast>>();
private readonly streamListeners = new Set<Listener<ConversationStreamEvent>>();
private readonly errorListeners = new Set<Listener<Error>>();
private readonly closeListeners = new Set<Listener<Error | undefined>>();
private readonly discoveryHandler?: (request: IpcRequest) => boolean | Promise<boolean>;
constructor(options: CodexIpcClientOptions = {}) {
this.options = {
...options,
clientType: options.clientType ?? "codex-remote-collab",
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_IPC_REQUEST_TIMEOUT_MS,
maxFrameBytes: options.maxFrameBytes ?? DEFAULT_MAX_IPC_FRAME_BYTES,
strictVersions: options.strictVersions ?? true,
autoReconnect: options.autoReconnect ?? false,
reconnectDelayMs: options.reconnectDelayMs ?? 1_000,
};
this.socketPath = resolveCodexIpcSocketPath(options);
this.decoder = new IpcFrameDecoder(this.options.maxFrameBytes);
this.discoveryHandler = options.canHandleRequest;
}
getClientId(): string { return this.clientId; }
getConversationState(conversationId: string): ConversationStreamState | undefined {
const state = this.streams.get(conversationId);
return state == null ? undefined : { ...state, conversationState: cloneJson(state.conversationState) };
}
getFollowedConversations(): ReadonlyMap<string, string> { return this.followed; }
onMessage(listener: Listener<IpcMessage>): IpcSubscription { return subscribe(this.messageListeners, listener); }
onBroadcast(listener: Listener<IpcBroadcast>): IpcSubscription { return subscribe(this.broadcastListeners, listener); }
onStreamEvent(listener: Listener<ConversationStreamEvent>): IpcSubscription { return subscribe(this.streamListeners, listener); }
onError(listener: Listener<Error>): IpcSubscription { return subscribe(this.errorListeners, listener); }
onClose(listener: Listener<Error | undefined>): IpcSubscription { return subscribe(this.closeListeners, listener); }
async connect(): Promise<string> {
if (this.disposed) throw new CodexIpcError("IPC client is disposed", { code: "disposed" });
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
if (this.socket?.writable && this.clientId !== INITIALIZING_CLIENT_ID) return this.clientId;
if (this.connectPromise) return this.connectPromise;
this.connectPromise = new Promise<string>((resolve, reject) => {
const socket = net.createConnection(this.socketPath);
this.socket = socket;
this.decoder.reset();
let settled = false;
const finishError = (error: Error): void => {
if (!settled) {
settled = true;
reject(error);
}
this.emitError(error);
};
socket.setNoDelay?.(true);
socket.on("connect", () => {
const requestId = crypto.randomUUID();
const timer = setTimeout(() => {
this.pending.delete(requestIdKey(requestId));
finishError(new CodexIpcError("IPC initialize timed out", { code: "timeout" }));
socket.destroy();
}, this.options.requestTimeoutMs);
this.pending.set(requestIdKey(requestId), {
method: "initialize",
resolve: (response) => {
clearTimeout(timer);
if (response.resultType !== "success" || !isRecord(response.result) || typeof response.result.clientId !== "string") {
finishError(new CodexIpcError("IPC initialize returned an invalid response", { code: "initialize-failed", response }));
socket.destroy();
return;
}
this.clientId = response.result.clientId;
settled = true;
resolve(this.clientId);
this.resubscribeAfterConnect().catch((error) => this.emitError(asError(error)));
},
reject: (error) => {
clearTimeout(timer);
finishError(error);
socket.destroy();
},
timer,
});
this.write({
type: "request",
requestId,
sourceClientId: INITIALIZING_CLIENT_ID,
version: 0,
method: "initialize",
params: { clientType: this.options.clientType },
});
});
socket.on("data", (chunk) => {
try {
for (const message of this.decoder.push(chunk)) this.handleMessage(message);
} catch (error) {
const normalized = asError(error);
finishError(normalized);
socket.destroy(normalized);
}
});
socket.on("error", (error) => {
if (!settled) finishError(error);
else this.emitError(error);
});
socket.on("close", () => {
this.handleClose();
});
}).finally(() => {
this.connectPromise = undefined;
});
return this.connectPromise;
}
async followConversation(conversationId: string, following = true, options: FollowOptions = {}): Promise<void> {
const hostId = options.hostId ?? "local";
await this.connect();
if (following) this.followed.set(conversationId, hostId);
else {
this.followed.delete(conversationId);
this.streams.delete(conversationId);
}
const params: JsonObject = { conversationId, hostId, following };
const frame: IpcBroadcast = {
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: this.clientId,
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
params,
};
if (options.targetClientIds) frame.targetClientIds = options.targetClientIds;
this.write(frame);
}
async findThreadOwner(conversationId: string, hostId = "local", timeoutMs = this.options.requestTimeoutMs): Promise<string | null> {
try {
const response = await this.request("thread-owner-discovery", { conversationId, hostId }, { timeoutMs });
return response.handledByClientId ?? null;
} catch (error) {
if (error instanceof CodexIpcError
&& (error.code === "no-client-found" || error.code.startsWith("no-client-found:"))) return null;
throw error;
}
}
async request(method: string, params?: JsonValue, options: RequestOptions = {}): Promise<IpcResponse> {
await this.connect();
const requestId = options.requestId ?? crypto.randomUUID();
const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs;
const frame: IpcRequest = {
type: "request",
requestId,
sourceClientId: this.clientId,
version: options.version ?? versionFor(method, params),
method,
params,
};
if (options.targetClientId) frame.targetClientId = options.targetClientId;
if (timeoutMs > 0) frame.timeoutMs = timeoutMs;
return new Promise<IpcResponse>((resolve, reject) => {
const key = requestIdKey(requestId);
const timer = setTimeout(() => {
this.pending.delete(key);
reject(new CodexIpcError(`${method} timed out`, { code: "timeout" }));
}, timeoutMs > 0 ? timeoutMs : 2 ** 31 - 1);
this.pending.set(key, { method, resolve, reject, timer });
try {
this.write(frame);
} catch (error) {
clearTimeout(timer);
this.pending.delete(key);
reject(asError(error));
}
}).then((response) => {
if (response.resultType === "error") {
throw new CodexIpcError(response.error ?? `${method} failed`, { code: response.error ?? "ipc-error", response });
}
if (response.method != null && response.method !== method) {
throw new CodexIpcError(`IPC response method mismatch: expected ${method}, got ${response.method}`, {
code: "response-method-mismatch",
response,
});
}
return response;
});
}
async requestFollower(method: string, conversationId: string, params: JsonObject = {}, options: RequestOptions & { ownerClientId?: string } = {}): Promise<IpcResponse> {
const ownerClientId = options.ownerClientId ?? this.streams.get(conversationId)?.ownerClientId;
if (!ownerClientId) throw new CodexIpcError(`No owner is known for conversation ${conversationId}`, { code: "owner-unknown" });
// Do not allow a caller-provided params object to accidentally retarget a
// request after the owner has been selected from the stream snapshot.
const body: JsonObject = { ...params, conversationId };
const { ownerClientId: _owner, ...requestOptions } = options;
return this.request(method, body, { ...requestOptions, targetClientId: ownerClientId });
}
/** Send the exact private `turnStart` envelope expected by the owner. */
async startTurn(conversationId: string, input: string | JsonValue[], options: FollowerTurnStartOptions = {}): Promise<JsonValue | undefined> {
const request: JsonObject = {
...(options.request ?? {}),
threadId: conversationId,
input: options.request?.input ?? normalizeInput(input),
};
const context: JsonObject = { inheritThreadSettings: true, ...(options.context ?? {}) };
if (options.clientUserMessageId) request.clientUserMessageId = options.clientUserMessageId;
const response = await this.requestFollower("thread-follower-start-turn", conversationId, {
turnStart: { request, context },
}, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
async steerTurn(conversationId: string, input: string | JsonValue[], options: FollowerSteerOptions = {}): Promise<JsonValue | undefined> {
const params: JsonObject = {
clientUserMessageId: options.clientUserMessageId ?? crypto.randomUUID(),
input: normalizeInput(input),
attachments: options.attachments ?? [],
};
if (options.serviceTier !== undefined) params.serviceTier = options.serviceTier;
if (options.additionalContext !== undefined) params.additionalContext = options.additionalContext;
if (options.restoreMessage !== undefined) params.restoreMessage = options.restoreMessage;
const response = await this.requestFollower("thread-follower-steer-turn", conversationId, params, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
/**
* Persist settings for the next turn through the official conversation
* owner. The owner-side follower handler expects the settings nested under
* `threadSettings`; `requestFollower` adds the conversation id to the
* outer envelope, yielding:
* `{ conversationId, threadSettings }`.
*/
async updateThreadSettings(
conversationId: string,
threadSettings: JsonObject,
options: RequestOptions & { ownerClientId?: string } = {},
): Promise<JsonValue | undefined> {
if (!isJsonObject(threadSettings)) {
throw new CodexIpcError("thread settings must be a JSON object", { code: "invalid-thread-settings" });
}
const response = await this.requestFollower(
"thread-follower-update-thread-settings",
conversationId,
{ threadSettings: cloneJson(threadSettings) },
options,
);
return response.result;
}
/** Alias matching the official app-server manager method name. */
async updateThreadSettingsForNextTurn(
conversationId: string,
threadSettings: JsonObject,
options: RequestOptions & { ownerClientId?: string } = {},
): Promise<JsonValue | undefined> {
return this.updateThreadSettings(conversationId, threadSettings, options);
}
async interruptTurn(conversationId: string, options: FollowerInterruptOptions = {}): Promise<JsonValue | undefined> {
const params: JsonObject = { mode: options.mode ?? "user-stop" };
if (options.expectedTurnId !== undefined && options.expectedTurnId !== null) params.expectedTurnId = options.expectedTurnId;
const response = await this.requestFollower("thread-follower-interrupt-turn", conversationId, params, {
ownerClientId: options.ownerClientId,
timeoutMs: options.timeoutMs,
});
return response.result;
}
async loadCompleteHistory(conversationId: string, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
const response = await this.requestFollower("thread-follower-load-complete-history", conversationId, {}, options);
return response.result;
}
async respondCommandApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-command-approval-decision", conversationId, { requestId, decision }, options);
}
async respondFileApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-file-approval-decision", conversationId, { requestId, decision }, options);
}
async respondPermissionsApproval(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-permissions-request-approval-response", conversationId, { requestId, response }, options);
}
async respondUserInput(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-submit-user-input", conversationId, { requestId, response }, options);
}
async respondMcpElicitation(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
return this.respondFollower("thread-follower-submit-mcp-server-elicitation-response", conversationId, { requestId, response }, options);
}
private async respondFollower(method: string, conversationId: string, params: JsonObject, options: RequestOptions & { ownerClientId?: string }): Promise<JsonValue | undefined> {
const response = await this.requestFollower(method, conversationId, params, options);
return response.result;
}
async dispose(): Promise<void> {
this.disposed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new CodexIpcError("IPC client disposed", { code: "disposed" }));
}
this.pending.clear();
this.socket?.destroy();
this.socket = undefined;
this.clientId = INITIALIZING_CLIENT_ID;
}
private write(message: IpcMessage): void {
if (!this.socket?.writable) throw new CodexIpcError("IPC socket is not connected", { code: "not-connected" });
this.socket.write(encodeIpcFrame(message, this.options.maxFrameBytes));
}
private handleMessage(message: IpcMessage): void {
for (const listener of this.messageListeners) safeCall(listener, message, (error) => this.emitError(error));
switch (message.type) {
case "response":
this.handleResponse(message);
return;
case "broadcast":
this.handleBroadcast(message);
return;
case "client-discovery-request":
this.handleDiscoveryRequest(message).catch((error) => this.emitError(asError(error)));
return;
case "request":
this.handleUnexpectedRequest(message);
return;
case "client-discovery-response":
// Discovery responses are consumed by the router, not by clients.
return;
}
}
private handleResponse(response: IpcResponse): void {
const key = requestIdKey(response.requestId);
const pending = this.pending.get(key);
if (!pending) return;
this.pending.delete(key);
clearTimeout(pending.timer);
pending.resolve(response);
}
private handleBroadcast(frame: IpcBroadcast): void {
if (!hasTarget(frame, this.clientId)) return;
for (const listener of this.broadcastListeners) safeCall(listener, frame, (error) => this.emitError(error));
if (frame.method === "thread-stream-state-changed") {
this.handleStreamStateBroadcast(frame);
} else if (frame.method === "thread-stream-following-status-requested") {
this.handleFollowingStatusRequested(frame);
}
}
/** Re-announce active subscriptions when an owner reconnects or hands off. */
private handleFollowingStatusRequested(frame: IpcBroadcast): void {
if (!isRecord(frame.params)) return;
if (this.options.strictVersions
&& frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"]) {
this.emitError(new CodexIpcError(`Unsupported thread following status version ${frame.version}`, { code: "version-mismatch" }));
return;
}
const conversationId = typeof frame.params.conversationId === "string"
? frame.params.conversationId
: undefined;
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
const requester = frame.sourceClientId;
if (!conversationId || !requester || requester === this.clientId) return;
if (this.followed.get(conversationId) !== hostId) return;
void this.followConversation(conversationId, true, {
hostId,
targetClientIds: [requester],
}).catch((error) => this.emitError(asError(error)));
}
private handleStreamStateBroadcast(frame: IpcBroadcast): void {
if (!isRecord(frame.params)) return;
const conversationId = typeof frame.params.conversationId === "string" ? frame.params.conversationId : undefined;
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
const change = frame.params.change;
if (!conversationId || !isRecord(change) || typeof change.type !== "string") return;
if (this.options.strictVersions && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"]) {
this.emitError(new CodexIpcError(`Unsupported thread stream version ${frame.version}`, { code: "version-mismatch" }));
return;
}
const ownerClientId = frame.sourceClientId ?? "";
if (change.type === "snapshot") {
if (typeof change.revision !== "number" || !isJsonObject(change.conversationState)) return;
const state: ConversationStreamState = {
conversationId,
hostId,
ownerClientId,
revision: change.revision,
conversationState: cloneJson(change.conversationState),
};
this.streams.set(conversationId, state);
this.emitStream({ kind: "snapshot", ...state, raw: frame });
return;
}
if (change.type !== "patches" || typeof change.baseRevision !== "number" || typeof change.revision !== "number" || !Array.isArray(change.patches)) return;
const current = this.streams.get(conversationId);
if (!current || current.ownerClientId !== ownerClientId || current.revision !== change.baseRevision) {
const expectedRevision = current?.revision ?? 0;
this.emitStream({
kind: "desync",
conversationId,
hostId,
ownerClientId,
expectedRevision,
receivedBaseRevision: change.baseRevision,
receivedRevision: change.revision,
raw: frame,
});
// Re-sending `following:true` is how the official follower asks the
// owner for a fresh snapshot when a patch base revision is missed.
if (this.followed.has(conversationId)) {
this.followConversation(conversationId, true, { hostId }).catch((error) => this.emitError(asError(error)));
}
return;
}
try {
const patches = change.patches as unknown as IpcJsonPatch[];
const nextConversationState = applyIpcPatches(current.conversationState, patches);
if (!isJsonObject(nextConversationState)) throw new CodexIpcError("Patched conversation state is not an object", { code: "invalid-patch" });
const next: ConversationStreamState = {
...current,
revision: change.revision,
conversationState: nextConversationState,
};
this.streams.set(conversationId, next);
this.emitStream({ kind: "patches", ...next, patches, baseRevision: change.baseRevision, raw: frame });
} catch (error) {
this.emitError(asError(error));
}
}
private async handleDiscoveryRequest(message: IpcClientDiscoveryRequest): Promise<void> {
const request = message.request;
let canHandle = false;
try {
canHandle = this.discoveryHandler ? await this.discoveryHandler(request) : false;
} catch {
canHandle = false;
}
this.write({
type: "client-discovery-response",
requestId: message.requestId,
response: { canHandle },
});
}
private handleUnexpectedRequest(request: IpcRequest): void {
try {
this.write({
type: "response",
requestId: request.requestId,
resultType: "error",
error: "no-handler-for-request",
});
} catch (error) {
this.emitError(asError(error));
}
}
private async resubscribeAfterConnect(): Promise<void> {
const subscriptions = [...this.followed.entries()];
for (const [conversationId, hostId] of subscriptions) {
this.write({
type: "broadcast",
method: "thread-stream-following-changed",
sourceClientId: this.clientId,
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
params: { conversationId, hostId, following: true },
});
}
}
private handleClose(): void {
const socket = this.socket;
this.socket = undefined;
this.decoder.reset();
const closeError = new CodexIpcError("IPC socket closed", { code: "connection-closed" });
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(closeError);
}
this.pending.clear();
this.clientId = INITIALIZING_CLIENT_ID;
for (const listener of this.closeListeners) safeCall(listener, closeError, (error) => this.emitError(error));
if (!this.disposed && this.options.autoReconnect && socket) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.connect().catch((error) => this.emitError(asError(error)));
}, this.options.reconnectDelayMs);
}
}
private emitStream(event: ConversationStreamEvent): void {
for (const listener of this.streamListeners) safeCall(listener, event, (error) => this.emitError(error));
}
private emitError(error: Error): void {
for (const listener of this.errorListeners) safeCall(listener, error, () => undefined);
}
}
function safeCall<T>(listener: Listener<T>, value: T, onError: (error: Error) => void): void {
try {
listener(value);
} catch (error) {
onError(asError(error));
}
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import { accessSync, constants, Dirent, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join, sep } from "node:path";
export interface CodexPathOptions {
/** Environment used for PATH lookup. Defaults to the extension host environment. */
env?: NodeJS.ProcessEnv;
/** Home directory used when looking for bundled installations. */
homeDir?: string;
/** Platform override for deterministic tests. */
platform?: NodeJS.Platform;
}
/**
* Resolve the executable used by the VS Code bridge.
*
* VS Code launched from Finder/Dock often receives a smaller PATH than a shell.
* The default `codex` command therefore gets a few explicit installation
* fallbacks, while a user-supplied command remains authoritative.
*/
export function resolveCodexCommand(configuredCommand = "codex", options: CodexPathOptions = {}): string {
const command = configuredCommand.trim() || "codex";
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const home = options.homeDir ?? homedir();
if (hasPathComponent(command, platform)) {
const resolved = executablePath(command, platform);
if (resolved) return resolved;
throw missingCodexError(command, platform);
}
const fromPath = findOnPath(command, env.PATH, platform, env.PATHEXT);
if (fromPath) return fromPath;
// Only the default command gets installation-specific fallbacks. A custom
// bare command should fail loudly instead of silently running another binary.
if (!isDefaultCommand(command, platform)) throw missingCodexError(command, platform);
for (const candidate of bundledCandidates(home, platform)) {
const resolved = executablePath(candidate, platform);
if (resolved) return resolved;
}
throw missingCodexError(command, platform);
}
export function missingCodexError(command: string, platform: NodeJS.Platform = process.platform): Error {
const examples = platform === "darwin"
? ' Set "codexRemoteCollab.codexCommand" to the full path, for example "/Applications/ChatGPT.app/Contents/Resources/codex".'
: ' Set "codexRemoteCollab.codexCommand" to the full path of the Codex executable.';
return new Error(`Codex executable "${command}" was not found.${examples}`);
}
function isDefaultCommand(command: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? command.toLowerCase() === "codex" || command.toLowerCase() === "codex.exe" : command === "codex";
}
function hasPathComponent(command: string, platform: NodeJS.Platform): boolean {
return isAbsolute(command) || command.includes(sep) || (platform === "win32" && command.includes("\\"));
}
function executablePath(candidate: string, platform: NodeJS.Platform): string | undefined {
try {
const info = statSync(candidate);
if (!info.isFile()) return undefined;
// X_OK is meaningful on POSIX; Windows still benefits from the file check.
if (platform !== "win32") accessSync(candidate, constants.X_OK);
return candidate;
} catch {
return undefined;
}
}
function findOnPath(command: string, pathValue: string | undefined, platform: NodeJS.Platform, pathextValue?: string): string | undefined {
if (!pathValue) return undefined;
const extensions = platform === "win32" ? windowsExtensions(command, pathextValue) : [""];
for (const directory of pathValue.split(delimiter)) {
if (!directory) continue;
for (const extension of extensions) {
const candidate = join(directory, `${command}${extension}`);
const resolved = executablePath(candidate, platform);
if (resolved) return resolved;
}
}
return undefined;
}
function windowsExtensions(command: string, pathextValue: string | undefined): string[] {
if (/[.][^./\\]+$/.test(command)) return [""];
const extensions = (pathextValue ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((value) => value.trim())
.filter(Boolean);
return ["", ...extensions];
}
function bundledCandidates(home: string, platform: NodeJS.Platform): string[] {
if (platform !== "darwin") return [];
const candidates = [
join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"),
"/Applications/ChatGPT.app/Contents/Resources/codex",
join(home, ".local", "bin", "codex"),
join(home, ".npm-global", "bin", "codex"),
];
for (const extensionsRoot of [
join(home, ".vscode", "extensions"),
join(home, ".vscode-insiders", "extensions"),
]) {
candidates.push(...officialExtensionCandidates(extensionsRoot));
}
return candidates;
}
function officialExtensionCandidates(extensionsRoot: string): string[] {
let entries: Dirent<string>[];
try {
entries = readdirSync(extensionsRoot, { withFileTypes: true, encoding: "utf8" });
} catch {
return [];
}
const matches = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-"))
.map((entry) => {
const directory = join(extensionsRoot, entry.name);
let modified = 0;
try {
modified = statSync(directory).mtimeMs;
} catch {
// Keep an unreadable entry at the end of the deterministic sort.
}
return { directory, modified };
})
.sort((left, right) => right.modified - left.modified || right.directory.localeCompare(left.directory));
const candidates: string[] = [];
for (const match of matches) {
let architectures: Dirent<string>[];
try {
architectures = readdirSync(join(match.directory, "bin"), { withFileTypes: true, encoding: "utf8" });
} catch {
continue;
}
for (const architecture of architectures) {
if (architecture.isDirectory()) candidates.push(join(match.directory, "bin", architecture.name, "codex"));
}
}
return candidates;
}
@@ -0,0 +1,131 @@
import { Disposable, RelayFrame, RelayTransport } from "./protocol";
export interface NamedRelayTransport {
id: string;
transport: RelayTransport;
required?: boolean;
}
/**
* Fans host events out to local and cloud relays while presenting one
* transport lifecycle to RelayHost. A temporary cloud outage must not stop
* the local bridge (and vice versa).
*/
export class CompositeRelayTransport implements RelayTransport {
readonly handlesHandshake = true;
private readonly entries: NamedRelayTransport[];
private readonly subscriptions: Disposable[] = [];
private readonly openEntries = new Set<string>();
private readonly messageListeners = new Set<(frame: RelayFrame) => void>();
private readonly openListeners = new Set<() => void>();
private readonly closeListeners = new Set<(error?: Error) => void>();
private started = false;
private sessionId?: string;
constructor(entries: NamedRelayTransport[]) {
if (entries.length === 0) throw new Error("CompositeRelayTransport requires at least one relay");
const ids = new Set<string>();
for (const entry of entries) {
if (!entry.id || ids.has(entry.id)) throw new Error(`duplicate relay id: ${entry.id || "(empty)"}`);
ids.add(entry.id);
}
this.entries = [...entries];
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
for (const { transport } of this.entries) {
(transport as RelayTransport & { setSessionId?: (value: string) => void }).setSessionId?.(sessionId);
}
}
async connect(): Promise<void> {
if (this.started) return;
this.started = true;
this.bindTransports();
if (this.sessionId) this.setSessionId(this.sessionId);
const results = await Promise.allSettled(this.entries.map(({ transport }) => transport.connect()));
const failures = results
.map((result, index) => ({ result, entry: this.entries[index] }))
.filter((item): item is { result: PromiseRejectedResult; entry: NamedRelayTransport } => item.result.status === "rejected");
const requiredFailure = failures.find(({ entry }) => entry.required);
const connected = results.length - failures.length;
if (requiredFailure || connected === 0) {
this.started = false;
this.disposeSubscriptions();
for (const { transport } of this.entries) transport.close();
const detail = failures.map(({ entry, result }) => `${entry.id}: ${errorMessage(result.reason)}`).join("; ");
throw new Error(`unable to connect relay${failures.length === 1 ? "" : "s"}: ${detail}`);
}
}
send(frame: RelayFrame): void {
const failures: string[] = [];
for (const { id, transport } of this.entries) {
try {
transport.send(frame);
} catch (error) {
failures.push(`${id}: ${errorMessage(error)}`);
}
}
if (failures.length === this.entries.length) {
throw new Error(`all relay sends failed: ${failures.join("; ")}`);
}
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.messageListeners.add(listener);
return { dispose: () => this.messageListeners.delete(listener) };
}
onOpen(listener: () => void): Disposable {
this.openListeners.add(listener);
return { dispose: () => this.openListeners.delete(listener) };
}
onClose(listener: (error?: Error) => void): Disposable {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
isConnected(id: string): boolean {
return this.openEntries.has(id);
}
close(): void {
this.started = false;
this.openEntries.clear();
this.disposeSubscriptions();
for (const { transport } of this.entries) transport.close();
}
private bindTransports(): void {
for (const { id, transport } of this.entries) {
this.subscriptions.push(transport.onMessage((frame) => {
for (const listener of this.messageListeners) listener(frame);
}));
if (transport.onOpen) this.subscriptions.push(transport.onOpen(() => {
this.openEntries.add(id);
// RelayHost publishes an authoritative snapshot after an authenticated
// reconnect. Surface every member reconnect so a recovered cloud relay
// is hydrated even while the local relay remained online.
for (const listener of this.openListeners) listener();
}));
if (transport.onClose) this.subscriptions.push(transport.onClose((error) => {
const wasOpen = this.openEntries.delete(id);
if (wasOpen && this.openEntries.size === 0) {
for (const listener of this.closeListeners) listener(error);
}
}));
}
}
private disposeSubscriptions(): void {
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,574 @@
import { hostname } from "node:os";
import * as vscode from "vscode";
import { CodexAgentAdapter } from "./codexAgentAdapter";
import { resolveCodexCommand } from "./codexPath";
import { CodexIpcAgentAdapter } from "./codexIpcAgentAdapter";
import { CompositeRelayTransport } from "./compositeRelay";
import { LocalRelayController, localRelayTarget } from "./localRelay";
import { AgentAdapter, ControlMode, Disposable, JsonObject, Logger } from "./protocol";
import { RelayClient } from "./relayClient";
import { RelayHost } from "./relayHost";
import { SwitchableAgentAdapter } from "./switchableAgentAdapter";
let activeHost: RelayHost | undefined;
let activeAdapter: AgentAdapter | undefined;
let activeRelay: CompositeRelayTransport | undefined;
let activeAdapterStatusSubscription: Disposable | undefined;
let statusItem: vscode.StatusBarItem | undefined;
let autoStartRetryTimer: NodeJS.Timeout | undefined;
let autoStartRetryMs = 3_000;
let localRelayController: LocalRelayController | undefined;
const t = (message: string, ...args: Array<string | number | boolean>): string => vscode.l10n.t(message, ...args);
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const output = vscode.window.createOutputChannel(t("Codex Remote Collaboration"));
context.subscriptions.push(output);
const logger = {
debug: (message: string, ...args: unknown[]) => output.appendLine(`[debug] ${message} ${formatArgs(args)}`),
info: (message: string, ...args: unknown[]) => output.appendLine(`[info] ${message} ${formatArgs(args)}`),
warn: (message: string, ...args: unknown[]) => output.appendLine(`[warn] ${message} ${formatArgs(args)}`),
error: (message: string, ...args: unknown[]) => output.appendLine(`[error] ${message} ${formatArgs(args)}`),
};
localRelayController = new LocalRelayController({ extensionPath: context.extensionPath, logger });
statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusItem.command = "codexRemoteCollab.openWeb";
statusItem.text = "$(plug) Codex Remote";
statusItem.tooltip = t("Connecting to the local Codex collaboration service");
statusItem.show();
context.subscriptions.push(statusItem);
const start = async (automatic = false): Promise<void> => {
if (!automatic && autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
if (activeHost) {
if (!automatic) vscode.window.showInformationMessage(t("The Codex remote bridge is already running."));
return;
}
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const relayConfiguration = resolveRelayConfiguration(configuration);
const localRelayUrl = relayConfiguration.localUrl;
if (!localRelayUrl) {
vscode.window.showWarningMessage(t("Set codexRemoteCollab.localRelayUrl before starting the bridge."));
return;
}
const localTarget = localRelayTarget(localRelayUrl);
if (!localTarget) {
vscode.window.showErrorMessage(t("codexRemoteCollab.localRelayUrl must be a loopback ws:// address."));
return;
}
if (localTarget && configuration.get<boolean>("autoStartLocalRelay", true)) {
setStatus("$(sync~spin) Codex Remote", t("Starting {0}", localTarget.webUrl), "codexRemoteCollab.openWeb");
try {
await localRelayController?.ensureRunning(localRelayUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus("$(error) Codex Remote", t("Unable to start the local collaboration service: {0}", message), "codexRemoteCollab.openWeb");
if (automatic) {
logger.warn(`Automatic local relay start failed; retrying in ${autoStartRetryMs}ms`, error);
scheduleAutoStartRetry(start);
} else {
vscode.window.showErrorMessage(t("Unable to start the local Codex collaboration service: {0}", message));
}
return;
}
}
const legacyToken = await context.secrets.get("codexRemoteCollab.relayToken");
const localToken = relayConfiguration.legacyRemote ? undefined : legacyToken;
if (!localToken) logger.info("Using the loopback-only unauthenticated local relay");
const initialControlMode = resolveInitialControlMode(configuration);
let currentControlMode: ControlMode = initialControlMode;
const createAdapter = (controlMode: ControlMode): AgentAdapter => {
if (controlMode === "sync") {
const configuredThreadId = configuration.get<string>("threadId", "").trim();
const socketPath = configuration.get<string>("ipcSocketPath", "").trim();
logger.info(`Synchronous mode enabled; following the VS Code Codex panel${configuredThreadId ? ` (initial conversation ${configuredThreadId})` : ""}`);
return new CodexIpcAgentAdapter({
threadId: configuredThreadId || undefined,
socketPath: socketPath || undefined,
hostId: configuration.get<string>("hostId", "local"),
autoDiscoverThread: configuration.get<boolean>("autoDiscoverThread", true),
// Synchronous mode has one navigation owner: the official panel.
followVscodeSession: true,
preferredCwds: workspaceRoots(),
strictVersions: configuration.get<boolean>("ipcStrictVersions", true),
logger,
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
openNewSession: () => openOfficialNewSession(logger),
});
}
const configuredCommand = configuration.get<string>("codexCommand", "codex");
const command = resolveCodexCommand(configuredCommand);
const args = configuration.get<string[]>("codexArgs", ["app-server", "--stdio"]);
const defaultCwd = configuration.get<string>("defaultCwd", "") || firstWorkspaceRoot();
logger.info(`Asynchronous mode enabled; using independent Codex executable: ${command}`);
return new CodexAgentAdapter({
command,
args,
defaultCwd: defaultCwd || undefined,
logger,
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
});
};
const adapter = new SwitchableAgentAdapter({
initialMode: initialControlMode,
createAdapter,
logger,
onModeChanged: async (nextMode) => {
currentControlMode = nextMode;
await configuration.update("controlMode", nextMode, vscode.ConfigurationTarget.Global);
setControlModeStatus(nextMode, nextMode === "async" || Boolean((await adapter.snapshot()).threadId));
},
});
const localRelay = new RelayClient({
url: localRelayUrl,
...(localToken ? { accessToken: localToken } : {}),
reconnect: configuration.get<boolean>("relayReconnect", true),
logger,
});
const relayEntries = [{ id: "local", transport: localRelay, required: true }];
const cloudRelayUrl = relayConfiguration.cloudUrl;
const cloudToken = await context.secrets.get("codexRemoteCollab.cloudRelayToken")
?? (relayConfiguration.legacyRemote ? legacyToken : undefined);
if (cloudRelayUrl && cloudToken) {
relayEntries.push({
id: "aether-cloud",
transport: new RelayClient({
url: cloudRelayUrl,
accessToken: cloudToken,
reconnect: configuration.get<boolean>("relayReconnect", true),
logger,
}),
required: false,
});
logger.info(`Aether cloud relay enabled: ${cloudRelayUrl}`);
} else if (cloudRelayUrl) {
logger.warn("Aether cloud relay URL is configured without a device credential; cloud sync is disabled until pairing is completed");
}
const relay = new CompositeRelayTransport(relayEntries);
const capabilities = ["read_output", "send_task_input", "cancel_task", "approve_low_risk"];
if (configuration.get<boolean>("allowHighRiskApprovals", false)) capabilities.push("approve_high_risk");
const host = new RelayHost({ adapter, relay, logger, capabilities });
let controlReady = initialControlMode === "async";
activeAdapterStatusSubscription?.dispose();
const adapterStatusSubscription = adapter.onEvent((event) => {
if (activeAdapter !== adapter) return;
if (event.type === "control.mode.changed") {
const changedMode = event.payload.controlMode;
if (changedMode === "sync" || changedMode === "async") currentControlMode = changedMode;
}
if (event.type !== "session.snapshot") return;
const metadata = event.payload.metadata;
if (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)) {
const snapshotMode = (metadata as JsonObject).controlMode;
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
}
const waiting = event.payload.state === "waiting_for_host"
|| (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
&& (metadata as JsonObject).waitingForSession === true);
const threadId = event.threadId
?? (typeof event.payload.threadId === "string" ? event.payload.threadId : undefined);
controlReady = currentControlMode === "async" || (Boolean(threadId) && !waiting);
setControlModeStatus(currentControlMode, controlReady);
});
activeAdapterStatusSubscription = adapterStatusSubscription;
activeAdapter = adapter;
activeRelay = relay;
activeHost = host;
if (configuration.get<boolean>("autoStartLocalRelay", true)) {
localRelay.onClose(() => {
if (activeHost !== host) return;
setStatus("$(sync~spin) Codex Remote", t("Restoring the local collaboration service"), "codexRemoteCollab.openWeb");
void localRelayController?.ensureRunning(localRelayUrl).catch((error) => {
logger.warn("Unable to recover bundled local relay", error);
setStatus("$(error) Codex Remote", t("Unable to restore the local collaboration service"), "codexRemoteCollab.openWeb");
});
});
localRelay.onOpen(() => {
if (activeHost === host) {
setControlModeStatus(currentControlMode, controlReady);
}
});
}
try {
await host.start();
autoStartRetryMs = 3_000;
const snapshot = await adapter.snapshot();
const snapshotMode = snapshot.metadata?.controlMode;
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
controlReady = currentControlMode === "async" || Boolean(snapshot.threadId);
setControlModeStatus(currentControlMode, controlReady);
if (!automatic && (currentControlMode === "async" || controlReady)) {
vscode.window.showInformationMessage(currentControlMode === "sync"
? t("The Codex remote bridge attached to the existing VS Code Codex conversation.")
: t("The independent Codex remote mode connected."));
}
} catch (error) {
activeHost = undefined;
activeAdapter = undefined;
activeRelay = undefined;
if (activeAdapterStatusSubscription === adapterStatusSubscription) {
activeAdapterStatusSubscription.dispose();
activeAdapterStatusSubscription = undefined;
}
await host.stop().catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
if (initialControlMode === "sync" && isAttachSessionUnavailable(message)) {
setStatus("$(sync~spin) Codex Remote", t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."), "codexRemoteCollab.openWeb");
logger.info(`No attachable VS Code Codex session is available; retrying in ${autoStartRetryMs}ms`);
scheduleAutoStartRetry(start);
return;
}
setStatus("$(error) Codex Remote", initialControlMode === "sync" ? t("The Codex conversation is not connected") : t("The independent Codex mode is not connected"), "codexRemoteCollab.openWeb");
if (automatic) {
logger.warn(`Automatic bridge start failed; retrying in ${autoStartRetryMs}ms`, error);
scheduleAutoStartRetry(start);
} else {
const detail = localTarget && /ECONNREFUSED|connect refused/i.test(message)
? t("The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.", localTarget.webUrl)
: t("Unable to start the Codex remote bridge: {0}", message);
vscode.window.showErrorMessage(detail);
}
}
};
const stop = async (): Promise<void> => {
if (autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
autoStartRetryMs = 3_000;
const host = activeHost;
activeHost = undefined;
activeAdapter = undefined;
activeRelay = undefined;
activeAdapterStatusSubscription?.dispose();
activeAdapterStatusSubscription = undefined;
if (host) await host.stop();
setStatus("$(plug) Codex Remote", t("Bridge paused. Click to open the web control and resume automatically."), "codexRemoteCollab.openWeb");
};
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.openWeb", async () => {
const localRelayUrl = resolveRelayConfiguration(vscode.workspace.getConfiguration("codexRemoteCollab")).localUrl;
const webUrl = localRelayController?.getWebUrl(localRelayUrl);
if (!webUrl) {
vscode.window.showErrorMessage(t("The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl."));
return;
}
if (!activeHost) await start(false);
if (activeHost) await vscode.env.openExternal(vscode.Uri.parse(webUrl));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.start", start));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.stop", stop));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setThreadId", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const current = configuration.get<string>("threadId", "");
const value = await vscode.window.showInputBox({
prompt: t("Existing Codex conversation ID (leave blank for auto-discovery)"),
value: current,
ignoreFocusOut: true,
});
if (value === undefined) return;
await configuration.update("threadId", value.trim(), vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(value.trim()
? t("Codex Remote will attach to {0} after the next bridge start.", value.trim())
: t("Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setRelayToken", async () => {
const token = await vscode.window.showInputBox({ prompt: t("Relay access token (leave blank for the local relay)"), password: true, ignoreFocusOut: true });
if (token === undefined) return;
await context.secrets.store("codexRemoteCollab.relayToken", token);
vscode.window.showInformationMessage(t("Relay token stored in VS Code SecretStorage."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.configureCloud", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const currentUrl = configuration.get<string>("cloudRelayUrl", "");
const url = await vscode.window.showInputBox({
prompt: t("Aether cloud relay WebSocket URL"),
value: currentUrl,
placeHolder: "wss://aether.example.com/api/vscodex/ws",
ignoreFocusOut: true,
validateInput: validateCloudRelayUrl,
});
if (url === undefined) return;
if (!url.trim()) {
await configuration.update("cloudRelayUrl", "", vscode.ConfigurationTarget.Global);
await context.secrets.delete("codexRemoteCollab.cloudRelayToken");
vscode.window.showInformationMessage(t("Aether cloud connection removed. Local control remains enabled."));
return;
}
const token = await vscode.window.showInputBox({
prompt: t("Device credential from the Aether pairing flow"),
password: true,
ignoreFocusOut: true,
});
if (token === undefined) return;
if (!token.trim()) {
vscode.window.showWarningMessage(t("A non-empty Aether device credential is required."));
return;
}
await configuration.update("cloudRelayUrl", url.trim(), vscode.ConfigurationTarget.Global);
await context.secrets.store("codexRemoteCollab.cloudRelayToken", token.trim());
vscode.window.showInformationMessage(t("Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available."));
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.pairCloud", async () => {
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
const currentBaseUrl = configuration.get<string>("aetherUrl", "");
const baseUrl = await vscode.window.showInputBox({
prompt: t("Aether server URL"),
value: currentBaseUrl,
placeHolder: "https://aether.example.com",
ignoreFocusOut: true,
validateInput: validateAetherBaseUrl,
});
if (baseUrl === undefined || !baseUrl.trim()) return;
const code = await vscode.window.showInputBox({
prompt: t("One-time pairing code shown in Aether"),
placeHolder: "ABCD-EFGH",
ignoreFocusOut: true,
validateInput: (value) => normalizePairingCode(value).length === 8 ? undefined : t("Enter the 8-character pairing code."),
});
if (code === undefined || !code.trim()) return;
try {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const response = await fetch(`${normalizedBaseUrl}/api/vscodex/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: normalizePairingCode(code), name: hostname() || "VS Code" }),
});
const raw = await response.text();
let result: unknown;
try {
result = JSON.parse(raw);
} catch {
result = null;
}
if (!response.ok) {
const detail = isJsonRecord(result) && typeof result.error === "string" ? result.error : `HTTP ${response.status}`;
throw new Error(detail);
}
if (!isJsonRecord(result) || typeof result.device_token !== "string" || typeof result.ws_url !== "string") {
throw new Error(t("Aether returned an invalid pairing response."));
}
const wsError = validateCloudRelayUrl(result.ws_url);
if (wsError) throw new Error(wsError);
await configuration.update("aetherUrl", normalizedBaseUrl, vscode.ConfigurationTarget.Global);
await configuration.update("cloudRelayUrl", result.ws_url, vscode.ConfigurationTarget.Global);
await context.secrets.store("codexRemoteCollab.cloudRelayToken", result.device_token);
if (activeHost) await stop();
await start(false);
if (!activeHost) return;
if (activeRelay?.isConnected("aether-cloud")) {
vscode.window.showInformationMessage(t("Aether pairing completed. Local and cloud control are both active."));
} else {
vscode.window.showWarningMessage(t("Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry."));
}
} catch (error) {
vscode.window.showErrorMessage(t("Unable to pair with Aether: {0}", error instanceof Error ? error.message : String(error)));
}
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.sendInput", async () => {
if (!activeAdapter) {
vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
return;
}
const text = await vscode.window.showInputBox({ prompt: t("Send input to the active Codex turn"), ignoreFocusOut: true });
if (text === undefined || !text.trim()) return;
try {
await activeAdapter.sendInput(text);
} catch (error) {
vscode.window.showErrorMessage(t("Unable to send Codex input: {0}", error instanceof Error ? error.message : String(error)));
}
}));
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.snapshot", async () => {
if (!activeAdapter) return vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
const snapshot = await activeAdapter.snapshot();
output.appendLine(JSON.stringify(snapshot));
output.show(true);
}));
if (vscode.workspace.getConfiguration("codexRemoteCollab").get<boolean>("autoStart", true)) await start(true);
}
export async function deactivate(): Promise<void> {
if (autoStartRetryTimer) {
clearTimeout(autoStartRetryTimer);
autoStartRetryTimer = undefined;
}
const host = activeHost;
activeHost = undefined;
activeAdapter = undefined;
activeAdapterStatusSubscription?.dispose();
activeAdapterStatusSubscription = undefined;
if (host) await host.stop();
const localRelay = localRelayController;
localRelayController = undefined;
if (localRelay) await localRelay.stop();
}
function firstWorkspaceRoot(): string | undefined {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
function workspaceRoots(): string[] {
return (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath);
}
function setStatus(text: string, tooltip: string, command?: string): void {
if (!statusItem) return;
statusItem.text = text;
statusItem.tooltip = tooltip;
statusItem.command = command;
}
function setAttachStatus(ready: boolean): void {
setStatus(
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
ready ? t("Attached to the existing Codex conversation. Click to open the web control.") : t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."),
"codexRemoteCollab.openWeb",
);
}
function setControlModeStatus(mode: ControlMode, ready: boolean): void {
if (mode === "sync") {
setAttachStatus(ready);
return;
}
setStatus(
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
ready
? t("Independent Codex mode is connected. Click to open the web control.")
: t("Starting the independent Codex mode."),
"codexRemoteCollab.openWeb",
);
}
function scheduleAutoStartRetry(start: (automatic?: boolean) => Promise<void>): void {
if (autoStartRetryTimer) return;
const delay = autoStartRetryMs;
autoStartRetryMs = Math.min(autoStartRetryMs * 2, 30_000);
autoStartRetryTimer = setTimeout(() => {
autoStartRetryTimer = undefined;
void start(true);
}, delay);
}
function formatArgs(args: unknown[]): string {
return args.length ? args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") : "";
}
function isAttachSessionUnavailable(message: string): boolean {
return message.includes("没有找到已打开的 VS Code Codex 会话")
|| /找不到会话\s+.+\s+的 VS Code Codex owner/.test(message);
}
function validateCloudRelayUrl(value: string): string | undefined {
if (!value.trim()) return undefined;
try {
const url = new URL(value.trim());
if (url.protocol !== "wss:" && url.protocol !== "ws:") return t("Use a ws:// or wss:// URL.");
if (url.protocol === "ws:" && !isLoopbackHostname(url.hostname)) {
return t("Remote Aether connections must use wss://.");
}
return undefined;
} catch {
return t("Enter a valid WebSocket URL.");
}
}
function resolveRelayConfiguration(configuration: vscode.WorkspaceConfiguration): {
localUrl: string;
cloudUrl: string;
legacyRemote: boolean;
} {
const defaultLocalUrl = "ws://127.0.0.1:8787/v1/connect";
const explicitLocal = inspectedValue<string>(configuration.inspect<string>("localRelayUrl"));
const explicitCloud = inspectedValue<string>(configuration.inspect<string>("cloudRelayUrl"));
const explicitLegacy = inspectedValue<string>(configuration.inspect<string>("relayUrl"));
const legacyUrl = explicitLegacy?.trim() || "";
const legacyRemote = Boolean(legacyUrl && !localRelayTarget(legacyUrl));
const localUrl = (explicitLocal?.trim()
|| (!legacyRemote ? legacyUrl : "")
|| configuration.get<string>("localRelayUrl", defaultLocalUrl).trim()
|| defaultLocalUrl);
const cloudUrl = explicitCloud?.trim()
|| (legacyRemote ? legacyUrl : "")
|| configuration.get<string>("cloudRelayUrl", "").trim();
return { localUrl, cloudUrl, legacyRemote };
}
function inspectedValue<T>(inspection: ReturnType<vscode.WorkspaceConfiguration["inspect"]> | undefined): T | undefined {
if (!inspection) return undefined;
const values = inspection as {
globalLanguageValue?: T;
workspaceFolderLanguageValue?: T;
workspaceLanguageValue?: T;
workspaceFolderValue?: T;
workspaceValue?: T;
globalValue?: T;
};
return values.workspaceFolderLanguageValue
?? values.workspaceLanguageValue
?? values.globalLanguageValue
?? values.workspaceFolderValue
?? values.workspaceValue
?? values.globalValue;
}
function resolveInitialControlMode(configuration: vscode.WorkspaceConfiguration): ControlMode {
const configured = inspectedValue<ControlMode>(configuration.inspect<ControlMode>("controlMode"));
if (configured === "sync" || configured === "async") return configured;
const legacyMode = inspectedValue<"attach" | "spawn">(configuration.inspect<"attach" | "spawn">("mode"));
return legacyMode === "spawn" ? "async" : "sync";
}
function validateAetherBaseUrl(value: string): string | undefined {
if (!value.trim()) return t("Enter the Aether server URL.");
try {
const url = new URL(value.trim());
if (url.username || url.password || url.search || url.hash) return t("Use the Aether origin without credentials, a query, or a fragment.");
if (url.protocol === "https:") return undefined;
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return undefined;
return t("Remote Aether servers must use https://.");
} catch {
return t("Enter a valid URL.");
}
}
function normalizePairingCode(value: string): string {
return value.toUpperCase().replace(/[^A-Z2-9]/g, "");
}
function isLoopbackHostname(value: string): boolean {
const hostname = value.replace(/^\[|\]$/g, "").toLowerCase();
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
}
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Reuse the official extension's command registry for the header's new-chat
* action. This keeps the remote UI attached to the same VS Code Codex
* installation and avoids launching a second app-server process.
*/
async function openOfficialNewSession(logger: Logger): Promise<JsonObject> {
const commands = await vscode.commands.getCommands(true);
const command = commands.includes("chatgpt.newCodexPanel")
? "chatgpt.newCodexPanel"
: commands.includes("chatgpt.newChat")
? "chatgpt.newChat"
: undefined;
if (!command) {
throw new Error(t("The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled."));
}
await vscode.commands.executeCommand(command);
logger.info?.("Opened a new official Codex conversation with " + command);
return { opened: true, command };
}
@@ -0,0 +1,11 @@
export * from "./protocol";
export * from "./jsonlRpc";
export * from "./codexAgentAdapter";
export * from "./relayClient";
export * from "./compositeRelay";
export * from "./relayHost";
export * from "./bridge";
export * from "./codexPath";
export * from "./codexIpc";
export * from "./codexIpcAgentAdapter";
export * from "./switchableAgentAdapter";
@@ -0,0 +1,268 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { createInterface, Interface as ReadLineInterface } from "node:readline";
import {
asJsonValue,
Disposable,
isRecord,
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
JsonValue,
Logger,
jsonRpcIdKey,
} from "./protocol";
export interface JsonlRpcClientOptions {
command?: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
logger?: Logger;
/** Optional request timeout. Zero disables it, which is useful for long turns. */
requestTimeoutMs?: number;
}
interface PendingRequest {
method: string;
resolve: (value: JsonValue) => void;
reject: (reason: Error) => void;
timer?: NodeJS.Timeout;
}
export class JsonRpcRemoteError extends Error {
constructor(
message: string,
readonly code: number,
readonly data?: JsonValue,
) {
super(message);
this.name = "JsonRpcRemoteError";
}
}
/** Minimal newline-delimited JSON-RPC client used by `codex app-server --stdio`. */
export class JsonlRpcClient {
private readonly options: Required<Pick<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">> &
Omit<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">;
private child?: ChildProcessWithoutNullStreams;
private stdoutLines?: ReadLineInterface;
private nextId = 1;
private readonly pending = new Map<string, PendingRequest>();
private readonly notificationListeners = new Set<(message: JsonRpcNotification) => void>();
private readonly requestListeners = new Set<(message: JsonRpcRequest) => void>();
private readonly exitListeners = new Set<(error?: Error) => void>();
constructor(options: JsonlRpcClientOptions = {}) {
this.options = {
command: options.command ?? "codex",
args: options.args ?? ["app-server", "--stdio"],
requestTimeoutMs: options.requestTimeoutMs ?? 0,
cwd: options.cwd,
env: options.env,
logger: options.logger,
};
}
get running(): boolean {
return Boolean(this.child && this.child.exitCode === null && !this.child.killed);
}
async start(): Promise<void> {
if (this.running) return;
const child = spawn(this.options.command, this.options.args, {
cwd: this.options.cwd,
env: { ...process.env, ...(this.options.env ?? {}) },
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.child = child;
this.stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity });
this.stdoutLines.on("line", (line) => this.handleLine(line));
child.stderr.on("data", (chunk: Buffer) => {
const text = redactDiagnostic(chunk.toString("utf8").trim());
if (text) this.options.logger?.debug?.(`[app-server stderr] ${text}`);
});
child.once("exit", (code, signal) => {
const expected = child.killed;
const error = expected
? undefined
: new Error(`codex app-server exited (code=${String(code)}, signal=${String(signal)})`);
this.handleExit(error);
});
await new Promise<void>((resolve, reject) => {
const onSpawn = (): void => {
child.off("error", onError);
resolve();
};
const onError = (error: Error): void => {
child.off("spawn", onSpawn);
const spawnError = error as NodeJS.ErrnoException;
if (spawnError.code === "ENOENT") {
reject(new Error(`Codex executable "${this.options.command}" was not found. Set codexRemoteCollab.codexCommand to its full path.`));
return;
}
reject(error);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
}
request(method: string, params?: JsonValue): Promise<JsonValue> {
if (!this.running) return Promise.reject(new Error("app-server is not running"));
const id = this.nextId++;
return new Promise<JsonValue>((resolve, reject) => {
const pending: PendingRequest = { method, resolve, reject };
if (this.options.requestTimeoutMs > 0) {
pending.timer = setTimeout(() => {
this.pending.delete(jsonRpcIdKey(id));
reject(new Error(`app-server request timed out: ${method}`));
}, this.options.requestTimeoutMs);
}
this.pending.set(jsonRpcIdKey(id), pending);
try {
this.write({ id, method, ...(params === undefined ? {} : { params }) });
} catch (error) {
this.pending.delete(jsonRpcIdKey(id));
if (pending.timer) clearTimeout(pending.timer);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
notify(method: string, params?: JsonValue): void {
this.write({ method, ...(params === undefined ? {} : { params }) });
}
respond(id: JsonRpcId, result: JsonValue): void {
this.write({ id, result });
}
respondError(id: JsonRpcId, code: number, message: string, data?: JsonValue): void {
this.write({ id, error: { code, message, ...(data === undefined ? {} : { data }) } });
}
onNotification(listener: (message: JsonRpcNotification) => void): Disposable {
this.notificationListeners.add(listener);
return { dispose: () => this.notificationListeners.delete(listener) };
}
onServerRequest(listener: (message: JsonRpcRequest) => void): Disposable {
this.requestListeners.add(listener);
return { dispose: () => this.requestListeners.delete(listener) };
}
onExit(listener: (error?: Error) => void): Disposable {
this.exitListeners.add(listener);
return { dispose: () => this.exitListeners.delete(listener) };
}
close(): void {
const child = this.child;
this.child = undefined;
this.stdoutLines?.close();
this.stdoutLines = undefined;
if (child && child.exitCode === null && !child.killed) child.kill();
this.rejectAll(new Error("app-server client closed"));
}
private write(message: unknown): void {
const child = this.child;
if (!child || child.exitCode !== null || child.killed || !child.stdin.writable) {
throw new Error("app-server is not running");
}
child.stdin.write(`${JSON.stringify(message)}\n`, "utf8");
}
private handleLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) return;
let message: unknown;
try {
message = JSON.parse(trimmed);
} catch (error) {
this.options.logger?.warn?.("Ignoring malformed app-server JSON", error, trimmed.slice(0, 500));
return;
}
if (!isRecord(message)) return;
const hasId = typeof message.id === "string" || typeof message.id === "number";
const hasMethod = typeof message.method === "string";
if (hasId && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error")) && !hasMethod) {
this.handleResponse(message as Record<string, unknown> & { id: JsonRpcId });
return;
}
if (hasMethod && hasId) {
const request: JsonRpcRequest = {
id: message.id as JsonRpcId,
method: message.method as string,
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
};
for (const listener of this.requestListeners) listener(request);
return;
}
if (hasMethod) {
const notification: JsonRpcNotification = {
method: message.method as string,
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
};
for (const listener of this.notificationListeners) listener(notification);
return;
}
this.options.logger?.warn?.("Ignoring unknown app-server message", message);
}
private handleResponse(message: Record<string, unknown> & { id: JsonRpcId }): void {
const pending = this.pending.get(jsonRpcIdKey(message.id));
if (!pending) {
this.options.logger?.warn?.(`Received response for unknown app-server request ${String(message.id)}`);
return;
}
this.pending.delete(jsonRpcIdKey(message.id));
if (pending.timer) clearTimeout(pending.timer);
if (isRecord(message.error)) {
pending.reject(
new JsonRpcRemoteError(
typeof message.error.message === "string" ? message.error.message : `Request failed: ${pending.method}`,
typeof message.error.code === "number" ? message.error.code : -32000,
message.error.data === undefined ? undefined : asJsonValue(message.error.data),
),
);
return;
}
pending.resolve(message.result === undefined ? null : asJsonValue(message.result));
}
private handleExit(error?: Error): void {
this.child = undefined;
this.stdoutLines?.close();
this.stdoutLines = undefined;
this.rejectAll(error ?? new Error("app-server exited"));
for (const listener of this.exitListeners) listener(error);
}
private rejectAll(error: Error): void {
for (const request of this.pending.values()) {
if (request.timer) clearTimeout(request.timer);
request.reject(error);
}
this.pending.clear();
}
}
function redactDiagnostic(text: string): string {
return text
.replace(/Bearer\s+[A-Za-z0-9._~+\-/]+=*/gi, "Bearer [REDACTED]")
.replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})\b/g, "[REDACTED]")
.replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
}
@@ -0,0 +1,193 @@
import * as http from "node:http";
import * as path from "node:path";
import { Logger } from "./protocol";
interface BundledRelay {
start(): Promise<{ host: string; port: number }>;
stop(): Promise<void>;
}
interface BundledRelayModule {
CodexRelay: new (options: Record<string, unknown>) => BundledRelay;
}
export interface LocalRelayTarget {
host: string;
port: number;
healthUrl: string;
webUrl: string;
}
export interface LocalRelayControllerOptions {
extensionPath: string;
logger?: Logger;
probeTimeoutMs?: number;
relayModulePath?: string;
loadRelayModule?: (modulePath: string) => BundledRelayModule;
probeRelayHealth?: (url: string, timeoutMs?: number) => Promise<boolean>;
}
/**
* Owns the loopback relay bundled with the companion extension. Remote and
* TLS relay URLs deliberately stay outside this controller.
*/
export class LocalRelayController {
private readonly options: LocalRelayControllerOptions;
private relay?: BundledRelay;
private target?: LocalRelayTarget;
private starting?: Promise<boolean>;
private generation = 0;
constructor(options: LocalRelayControllerOptions) {
this.options = options;
}
async ensureRunning(relayUrl: string): Promise<boolean> {
const target = localRelayTarget(relayUrl);
if (!target) return false;
if ((this.relay || this.starting) && this.target?.healthUrl !== target.healthUrl) await this.stop();
const generation = this.generation;
this.target = target;
const available = await this.probeHealth(target.healthUrl);
// `stop()` may run while the health request is in flight. Do not let that
// completed probe resurrect a relay owned by a deactivated extension.
if (generation !== this.generation) return false;
if (available) return false;
if (this.relay) {
await this.relay.stop().catch(() => undefined);
this.relay = undefined;
}
if (this.starting) return this.starting;
this.starting = this.startBundledRelay(target).finally(() => {
this.starting = undefined;
});
return this.starting;
}
getWebUrl(relayUrl: string): string | undefined {
return localRelayTarget(relayUrl)?.webUrl;
}
async stop(): Promise<void> {
this.generation += 1;
const starting = this.starting;
if (starting) await starting.catch(() => undefined);
const relay = this.relay;
this.relay = undefined;
this.target = undefined;
if (relay) await relay.stop();
}
private async startBundledRelay(target: LocalRelayTarget): Promise<boolean> {
const modulePath = this.options.relayModulePath
?? path.join(this.options.extensionPath, "dist", "local-relay", "server.js");
let relay: BundledRelay;
try {
const load = this.options.loadRelayModule ?? ((value: string) => require(value) as BundledRelayModule);
const module = load(modulePath);
if (typeof module?.CodexRelay !== "function") throw new Error("bundled relay module is invalid");
relay = new module.CodexRelay({
host: target.host,
port: target.port,
mode: "host",
spawnCodex: false,
authRequired: false,
});
await relay.start();
} catch (error) {
// Another VS Code window can win the listen race after our health
// probe. Treat that as success only when the expected relay responds.
if (await this.probeHealth(target.healthUrl)) {
this.options.logger?.info?.(`Using existing local relay at ${target.webUrl}`);
return false;
}
throw error;
}
this.relay = relay;
this.options.logger?.info?.(`Started bundled local relay at ${target.webUrl}`);
return true;
}
private probeHealth(url: string): Promise<boolean> {
const probe = this.options.probeRelayHealth ?? relayHealthAvailable;
return probe(url, this.options.probeTimeoutMs);
}
}
export function localRelayTarget(relayUrl: string): LocalRelayTarget | undefined {
let url: URL;
try {
url = new URL(relayUrl);
} catch {
return undefined;
}
if (url.protocol !== "ws:" || !isLoopbackHostname(url.hostname)) return undefined;
const port = Number(url.port || 80);
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
const hostname = normalizeLoopbackHostname(url.hostname);
const authorityHost = hostname.includes(":") ? `[${hostname}]` : hostname;
return {
host: hostname,
port,
healthUrl: `http://${authorityHost}:${port}/api/health`,
webUrl: `http://${authorityHost}:${port}/`,
};
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function normalizeLoopbackHostname(hostname: string): string {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" ? "127.0.0.1" : normalized;
}
export function relayHealthAvailable(url: string, timeoutMs = 700): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
let timer: NodeJS.Timeout | undefined;
const finish = (available: boolean): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(available);
};
const request = http.get(url, (response) => {
if (response.statusCode !== 200) {
response.resume();
finish(false);
return;
}
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
if (body.length <= 16_384) body += chunk;
});
response.on("end", () => {
try {
const payload = JSON.parse(body) as { ok?: unknown };
finish(payload.ok === true);
} catch {
finish(false);
}
});
response.on("aborted", () => finish(false));
response.on("error", () => finish(false));
response.on("close", () => {
if (!response.complete) finish(false);
});
});
request.setTimeout(timeoutMs, () => {
request.destroy();
finish(false);
});
request.on("error", () => finish(false));
timer = setTimeout(() => {
request.destroy();
finish(false);
}, timeoutMs);
});
}
@@ -0,0 +1,496 @@
/**
* Wire types shared by the relay host and the Codex app-server adapter.
*
* The relay intentionally treats `payload` as JSON. Keeping this boundary
* unopinionated lets the bridge continue working when app-server adds a new
* notification or request before this extension is updated.
*/
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export type JsonRpcId = string | number;
/** Preserve the JSON-RPC id type when using it as a map key. */
export function jsonRpcIdKey(id: JsonRpcId): string {
return `${typeof id}:${String(id)}`;
}
export function isJsonRpcId(value: unknown): value is JsonRpcId {
return typeof value === "string" || typeof value === "number";
}
export type ApprovalDecisionKind = "allow" | "deny" | "cancel";
const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]);
const V2_APPROVAL_METHODS = new Set([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
]);
/**
* Classify both current and legacy app-server approval decisions without
* rewriting the wire value. Unknown tagged objects intentionally return
* `undefined` so callers can fail closed instead of accidentally approving a
* newly introduced response shape.
*/
export function approvalDecisionKind(value: unknown): ApprovalDecisionKind | undefined {
if (typeof value === "string") {
if (new Set([
"allow",
"accept",
"acceptForSession",
"approved",
"approved_for_session",
"approved_mcp_policy_amendment",
]).has(value)) return "allow";
if (new Set(["deny", "decline", "denied", "timed_out"]).has(value)) return "deny";
if (new Set(["cancel", "abort"]).has(value)) return "cancel";
return undefined;
}
if (!isRecord(value)) return undefined;
const keys = Object.keys(value);
if (keys.length !== 1) return undefined;
const key = keys[0];
const nested = value[key];
if (isExecpolicyAmendmentTag(key, nested) || isNetworkPolicyAmendmentTag(key, nested)) return "allow";
if (key === "denied" && isRecord(nested) && typeof nested.rejection === "string") return "deny";
return undefined;
}
/**
* Classify a decision against the response schema for one app-server method.
* The generic classifier above is intentionally useful for relay envelopes;
* this method-aware variant prevents a v2 tagged object from being sent to a
* legacy callback (or vice versa), while retaining compatibility aliases that
* the relay may use in its outer `decision` field.
*/
export function approvalDecisionKindForMethod(
value: unknown,
method?: string,
): ApprovalDecisionKind | undefined {
const generic = approvalDecisionKind(value);
if (!generic || !method) return generic;
if (LEGACY_APPROVAL_METHODS.has(method)) {
if (typeof value === "string") {
return new Set([
"approved",
"approved_for_session",
"approved_mcp_policy_amendment",
"timed_out",
"abort",
]).has(value) ? generic : undefined;
}
if (!isRecord(value)) return undefined;
const key = Object.keys(value)[0];
return key === "approved_execpolicy_amendment"
|| key === "network_policy_amendment"
|| key === "denied" ? generic : undefined;
}
if (V2_APPROVAL_METHODS.has(method)) {
if (typeof value === "string") {
return new Set(["accept", "acceptForSession", "decline", "cancel"]).has(value)
? generic
: undefined;
}
if (!isRecord(value)) return undefined;
const key = Object.keys(value)[0];
if (method === "item/fileChange/requestApproval") return undefined;
return key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"
? generic
: undefined;
}
if (method === "mcpServer/elicitation/request") {
return typeof value === "string" && new Set(["accept", "decline", "cancel"]).has(value)
? generic
: undefined;
}
return generic;
}
function isExecpolicyAmendmentTag(key: string, nested: unknown): boolean {
if (!isRecord(nested)) return false;
if (key === "acceptWithExecpolicyAmendment") {
return isStringArray(nested.execpolicy_amendment);
}
if (key === "approved_execpolicy_amendment") {
return isStringArray(nested.proposed_execpolicy_amendment);
}
return false;
}
function isNetworkPolicyAmendmentTag(key: string, nested: unknown): boolean {
if (!isRecord(nested)) return false;
if (key === "applyNetworkPolicyAmendment") {
return isNetworkPolicyAmendment(nested.network_policy_amendment);
}
if (key === "network_policy_amendment") {
return isNetworkPolicyAmendment(nested.network_policy_amendment);
}
return false;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isNetworkPolicyAmendment(value: unknown): boolean {
return isRecord(value)
&& typeof value.host === "string"
&& (value.action === "allow" || value.action === "deny");
}
/** Whether a response explicitly carries a decision/action field. */
export function hasApprovalDecisionField(value: unknown): value is Record<string, unknown> {
return isRecord(value) && (Object.prototype.hasOwnProperty.call(value, "decision")
|| Object.prototype.hasOwnProperty.call(value, "action"));
}
export interface Disposable {
dispose(): void;
}
export interface JsonRpcRequest {
id: JsonRpcId;
method: string;
params?: JsonValue;
}
export interface JsonRpcNotification {
method: string;
params?: JsonValue;
}
export interface JsonRpcResponse {
id: JsonRpcId;
result?: JsonValue;
error?: {
code: number;
message: string;
data?: JsonValue;
};
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
export type RelayRole = "owner" | "operator" | "approver" | "viewer" | string;
export interface RelayActor {
id?: string;
role?: RelayRole;
}
/** A versioned relay event frame. `seq` is normally assigned by the relay. */
export interface RelayEventFrame {
v: 1;
kind: "event";
type: string;
id: string;
sessionId: string;
seq?: number;
ts: string;
actor?: RelayActor;
payload: JsonObject;
/** Optional typed execution projection attached by a VS Code host. */
status?: AgentStatusSnapshot;
}
export interface RelayCommandFrame {
v?: 1;
kind?: "command";
type: string;
/** Compact relay compatibility form: `{ type: "command", method, params }`. */
method?: string;
params?: JsonObject;
commandId?: string;
id?: string;
sessionId?: string;
actor?: RelayActor;
payload?: JsonObject;
/** Some clients put the command body under `command`. */
command?: {
type?: string;
commandId?: string;
payload?: JsonObject;
[key: string]: JsonValue | undefined;
};
}
export interface RelayHelloFrame {
v: 1;
kind: "hello";
clientType: "host" | "web" | string;
protocol?: number;
accessToken?: string;
token?: string;
lastSeq?: number;
sessionId?: string;
payload?: JsonObject;
}
export interface RelayAckFrame {
v: 1;
kind: "ack";
sessionId: string;
seq: number;
}
export interface RelayErrorFrame {
v: 1;
kind: "error";
code: string;
message: string;
retryable?: boolean;
commandId?: string;
}
export type RelayFrame =
| RelayEventFrame
| RelayCommandFrame
| RelayHelloFrame
| RelayAckFrame
| RelayErrorFrame
| (JsonObject & { kind?: string; v?: number });
/**
* Live execution information projected from the official Codex conversation
* state. The private IPC protocol can add new turn statuses/flags, so the
* string fields intentionally remain open-ended for forward compatibility.
*/
export interface AgentStatusSnapshot {
/** Coarse UI activity, for example `thinking`, `editing`, or `running`. */
activity: string;
/** Raw/normalized turn status (`inProgress`, `completed`, ...). */
turnStatus: string;
/** Runtime flags such as `waitingOnApproval` or `waitingOnUserInput`. */
activeFlags: string[];
startedAtMs?: number | null;
durationMs?: number | null;
/**
* Time spent doing work in the official UI. This deliberately differs
* from `durationMs`: Codex starts the worked-for clock at the first work
* item and stops it when the final assistant response starts.
*/
workedDurationMs?: number | null;
/** Elapsed wall-clock time for an active turn. */
elapsedMs?: number | null;
firstTurnWorkItemStartedAtMs?: number | null;
finalAssistantStartedAtMs?: number | null;
error?: JsonValue;
}
/** Official background-agent lifecycle values emitted by Codex v2 items. */
export type CollabAgentStatus =
| "pendingInit"
| "running"
| "interrupted"
| "completed"
| "errored"
| "shutdown"
| "notFound"
| string;
export type CollabAgentTool =
| "spawnAgent"
| "sendInput"
| "resumeAgent"
| "wait"
| "closeAgent"
| string;
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | string;
export type SubAgentActivityKind = "started" | "interacted" | "interrupted" | "completed" | string;
/** Last known state for one receiver in a collabAgentToolCall item. */
export interface CollabAgentStateSnapshot {
status: CollabAgentStatus;
message?: string | null;
}
/**
* Browser-safe projection of a background Codex subagent. The official
* webview currently uses the four coarse statuses below; the string union is
* deliberately open so a newer app-server status does not break the relay.
*/
export interface SubagentSnapshot {
threadId: string;
displayName: string | null;
prompt: string | null;
/** Alias used by the subagent side panel for the same prompt text. */
objective?: string | null;
status: "waiting" | "working" | "done" | "failed" | string;
statusMessage: string | null;
startedAtMs?: number | null;
completedAtMs?: number | null;
canInteract?: boolean;
model?: string | null;
agentPath?: string | null;
parentThreadId?: string | null;
}
export interface AgentEvent {
/** Normalized relay event name, for example `output.chunk`. */
type: string;
threadId?: string;
turnId?: string;
requestId?: JsonRpcId;
payload: JsonObject;
/** Original app-server notification/request, when available. */
raw?: JsonValue;
/** Optional typed projection of live Codex turn/runtime status. */
status?: AgentStatusSnapshot;
}
export interface PendingApproval {
requestId: JsonRpcId;
method: string;
threadId?: string;
turnId?: string;
itemId?: string;
action: string;
risk: "low" | "medium" | "high" | "unknown";
summary: string;
/** SHA-256 of canonicalized, unredacted app-server request params. */
commandHash?: string;
createdAt: number;
expiresAt?: number;
payload: JsonObject;
}
export interface SessionSnapshot {
threadId: string | null;
turnId: string | null;
state: string;
pendingApprovals: PendingApproval[];
pendingRequests?: Array<{
requestId: JsonRpcId;
method: string;
params?: JsonValue;
commandHash?: string;
risk?: string;
summary?: string;
createdAt?: number;
expiresAt?: number;
}>;
outputTail: string;
/** Optional role-aware projection used by the browser renderer. */
messages?: JsonValue[];
/** Background/inline subagents reconstructed from official collab items. */
subagents?: SubagentSnapshot[];
/** Live execution projection; retained alongside the legacy `state` field. */
status?: AgentStatusSnapshot;
/** Convenience aliases for clients that do not consume `status` yet. */
activity?: string;
turnStatus?: string;
activeFlags?: string[];
startedAtMs?: number | null;
durationMs?: number | null;
workedDurationMs?: number | null;
elapsedMs?: number | null;
metadata?: JsonObject;
}
/** A live VS Code Codex conversation that the attach bridge has verified. */
export interface SessionListEntry {
threadId: string;
title: string;
updatedAtMs: number | null;
cwd?: string | null;
active: boolean;
/** True for attach-mode results; retained for wire compatibility. */
available: boolean;
}
export interface SessionListResult {
sessions: SessionListEntry[];
activeThreadId: string | null;
}
/** Which owner controls conversation navigation for the remote surface. */
export type ControlMode = "sync" | "async";
export interface AgentAdapter {
start(): Promise<void>;
/** Switch between following VS Code and independently owned conversations. */
setControlMode?(params: JsonObject): Promise<JsonValue>;
/** Return the currently committed control mode without taking a snapshot. */
getControlMode?(): ControlMode;
/** Start a new app-server thread. */
startThread?(params?: JsonObject): Promise<JsonValue>;
/** Ask the official VS Code Codex extension to open a fresh conversation. */
newSession?(params?: JsonObject): Promise<JsonValue>;
/** Start a turn; `threadId` may be supplied in params or use the active thread. */
startTurn?(params: JsonObject): Promise<JsonValue>;
/** Steer the active turn. */
steerTurn?(params: JsonObject): Promise<JsonValue>;
/** Persist model/effort and other owner-managed settings on the thread. */
updateThreadSettings?(params: JsonObject): Promise<JsonValue>;
/** List verified, attachable local conversations without starting another Codex process. */
listSessions?(params?: JsonObject): Promise<JsonValue>;
/** Attach the follower to another already-open conversation. */
selectSession?(params: JsonObject): Promise<JsonValue>;
/** Interrupt a turn. */
interruptTurn?(params: JsonObject): Promise<JsonValue>;
/** Convenience MVP aliases. */
sendInput(text: string, params?: JsonObject): Promise<JsonValue>;
cancel(taskId?: string, params?: JsonObject): Promise<JsonValue>;
respondApproval(
requestId: JsonRpcId,
decision: "allow" | "deny" | "cancel",
reason?: string,
response?: JsonValue,
): Promise<JsonValue>;
/** Resolve all pending approvals/inputs with a deny response. */
denyPending?(reason?: string): Promise<void>;
snapshot(): Promise<SessionSnapshot>;
onEvent(listener: (event: AgentEvent) => void): Disposable;
dispose(): Promise<void>;
}
export interface RelayTransport {
connect(): Promise<void>;
send(frame: RelayFrame): void;
onMessage(listener: (frame: RelayFrame) => void): Disposable;
onOpen?(listener: () => void): Disposable;
onClose?(listener: (error?: Error) => void): Disposable;
close(): void;
}
export interface Logger {
debug?(message: string, ...args: unknown[]): void;
info?(message: string, ...args: unknown[]): void;
warn?(message: string, ...args: unknown[]): void;
error?(message: string, ...args: unknown[]): void;
}
export const noopDisposable = (): Disposable => ({ dispose: () => undefined });
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asJsonObject(value: unknown): JsonObject {
return isRecord(value) ? (value as JsonObject) : {};
}
export function asJsonValue(value: unknown): JsonValue {
if (value === undefined) return null;
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (Array.isArray(value)) {
return value.map(asJsonValue);
}
if (isRecord(value)) {
const output: JsonObject = {};
for (const [key, item] of Object.entries(value)) {
if (item !== undefined) output[key] = asJsonValue(item);
}
return output;
}
return String(value);
}
@@ -0,0 +1,413 @@
import { createInterface, Interface as ReadLineInterface } from "node:readline";
import WebSocket from "ws";
import {
Disposable,
isRecord,
JsonObject,
Logger,
RelayFrame,
RelayHelloFrame,
RelayTransport,
} from "./protocol";
export interface RelayClientOptions {
url: string;
accessToken?: string;
sessionId?: string;
lastSeq?: number;
reconnect?: boolean;
reconnectInitialMs?: number;
reconnectMaxMs?: number;
maxFrameBytes?: number;
maxQueuedBytes?: number;
logger?: Logger;
/** Injectable constructor for tests or a browser-compatible WebSocket. */
webSocket?: new (url: string) => unknown;
}
type SocketLike = {
readyState?: number;
send(data: string): void;
close(): void;
on?(event: string, listener: (...args: any[]) => void): void;
addEventListener?(event: string, listener: (...args: any[]) => void): void;
};
const OPEN = 1;
// A structured Codex history snapshot is routinely larger than 256 KiB even
// though its plain-text tail is capped. Keep a bounded limit, but leave enough
// room for the message/tool projection of a long attached conversation.
export const DEFAULT_MAX_RELAY_FRAME_BYTES = 16 * 1024 * 1024;
export const DEFAULT_MAX_RELAY_QUEUE_BYTES = DEFAULT_MAX_RELAY_FRAME_BYTES + 2 * 1024 * 1024;
interface QueuedFrame {
serialized: string;
bytes: number;
projectionKey?: string;
}
const QUEUED_PROJECTION_TYPES = new Set(["session.snapshot", "output.snapshot", "output.chunk"]);
function queuedProjectionKey(frame: RelayFrame): string | undefined {
if (!isRecord(frame) || frame.kind !== "event" || typeof frame.type !== "string"
|| !QUEUED_PROJECTION_TYPES.has(frame.type)) return undefined;
const sessionId = typeof frame.sessionId === "string" ? frame.sessionId : "default";
return `${sessionId}:transcript`;
}
/** WebSocket relay transport with bounded reconnect and frame validation. */
export class RelayClient implements RelayTransport {
readonly handlesHandshake = true;
private readonly options: Required<
Pick<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">
> &
Omit<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">;
private socket?: SocketLike;
// A WebSocket can report OPEN while its relay authentication handshake is
// still in flight. Keep this separate from `socket` so events emitted by
// the adapter during reconnect are queued until the relay sends auth.ok.
private authenticatedSocket?: SocketLike;
private connecting?: Promise<void>;
// Incremented whenever a connection attempt is replaced or explicitly
// closed. Late events from an older WebSocket must not mutate newer state.
private connectionGeneration = 0;
private reconnectTimer?: NodeJS.Timeout;
private stopped = false;
private retryMs: number;
private readonly queue: QueuedFrame[] = [];
private queueBytes = 0;
private readonly listeners = new Set<(frame: RelayFrame) => void>();
private readonly openListeners = new Set<() => void>();
private readonly closeListeners = new Set<(error?: Error) => void>();
constructor(options: RelayClientOptions) {
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_RELAY_FRAME_BYTES;
const defaultMaxQueuedBytes = Math.max(
maxFrameBytes,
Math.min(DEFAULT_MAX_RELAY_QUEUE_BYTES, maxFrameBytes * 2),
);
this.options = {
...options,
reconnect: options.reconnect ?? true,
reconnectInitialMs: options.reconnectInitialMs ?? 500,
reconnectMaxMs: options.reconnectMaxMs ?? 10_000,
maxFrameBytes,
maxQueuedBytes: Math.max(1, Math.floor(options.maxQueuedBytes ?? defaultMaxQueuedBytes)),
};
this.retryMs = this.options.reconnectInitialMs;
}
/** Let RelayHost assign its stable session id before the first hello. */
setSessionId(sessionId: string): void {
this.options.sessionId = sessionId;
}
async connect(): Promise<void> {
this.stopped = false;
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) return;
if (this.connecting) return this.connecting;
const generation = ++this.connectionGeneration;
let connectionPromise: Promise<void>;
connectionPromise = new Promise<void>((resolve, reject) => {
let settled = false;
let authenticated = false;
const SocketCtor = this.options.webSocket ?? WebSocket;
let socket: SocketLike;
try {
socket = new SocketCtor(this.options.url) as SocketLike;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
this.socket = socket;
this.authenticatedSocket = undefined;
const isCurrent = (): boolean => this.connectionGeneration === generation && this.socket === socket;
const onOpen = (): void => {
if (!isCurrent() || settled || authenticated) return;
try {
// The TCP/WebSocket open event is only a transport milestone. Do
// not release queued commands until the relay has authenticated us.
this.sendHello(socket);
} catch (error) {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
}
};
const onMessage = (raw: unknown): void => {
if (!isCurrent()) return;
const data = extractMessageData(raw);
if (Buffer.byteLength(data, "utf8") > this.options.maxFrameBytes) {
this.options.logger?.warn?.("Ignoring oversized relay frame");
return;
}
let frame: unknown;
try {
frame = JSON.parse(data);
} catch {
this.options.logger?.warn?.("Ignoring malformed relay JSON");
return;
}
if (!isRecord(frame)) return;
if (frame.type === "auth.ok" && !authenticated && !settled) {
authenticated = true;
settled = true;
this.authenticatedSocket = socket;
this.retryMs = this.options.reconnectInitialMs;
try {
this.flush(socket);
} catch (error) {
this.options.logger?.warn?.("Unable to flush relay queue after authentication", error);
}
for (const listener of this.openListeners) listener();
resolve();
} else if (frame.type === "error" && !authenticated && !settled) {
settled = true;
reject(new Error(typeof frame.message === "string" ? frame.message : "relay authentication failed"));
}
if (frame.kind === "event" && typeof frame.seq === "number") {
this.options.lastSeq = Math.max(this.options.lastSeq ?? 0, frame.seq);
}
for (const listener of this.listeners) listener(frame as RelayFrame);
};
const onError = (raw: unknown): void => {
if (!isCurrent()) return;
const error = raw instanceof Error ? raw : new Error("relay websocket error");
this.options.logger?.warn?.(error.message);
if (!settled) {
settled = true;
reject(error);
}
};
const onClose = (): void => {
const current = isCurrent();
if (current) {
this.socket = undefined;
if (this.authenticatedSocket === socket) this.authenticatedSocket = undefined;
}
const error = new Error("relay websocket closed");
// A stale socket may still need to settle the promise returned to its
// caller, but it must never notify the active host or schedule a
// second reconnect loop.
if (!current) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
for (const listener of this.closeListeners) listener(error);
if (!settled) {
settled = true;
reject(error);
}
if (!this.stopped && this.options.reconnect) this.scheduleReconnect();
};
bindSocket(socket, onOpen, onMessage, onError, onClose);
// A small number of test/browser WebSocket implementations can already
// be OPEN by the time listeners are attached.
if (socket.readyState === OPEN) queueMicrotask(onOpen);
}).finally(() => {
if (this.connectionGeneration === generation && this.connecting === connectionPromise) {
this.connecting = undefined;
}
});
this.connecting = connectionPromise;
return connectionPromise;
}
send(frame: RelayFrame): void {
const serialized = JSON.stringify(frame);
const bytes = Buffer.byteLength(serialized, "utf8");
if (bytes > this.options.maxFrameBytes) {
throw new Error(`relay frame exceeds ${this.options.maxFrameBytes} bytes`);
}
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) {
this.socket.send(serialized);
return;
}
this.enqueue({ serialized, bytes, projectionKey: queuedProjectionKey(frame) });
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
onOpen(listener: () => void): Disposable {
this.openListeners.add(listener);
return { dispose: () => this.openListeners.delete(listener) };
}
onClose(listener: (error?: Error) => void): Disposable {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
close(): void {
this.stopped = true;
this.connectionGeneration += 1;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
this.connecting = undefined;
const socket = this.socket;
this.socket = undefined;
this.authenticatedSocket = undefined;
if (socket && socket.readyState !== 3) socket.close();
this.queue.length = 0;
this.queueBytes = 0;
}
private sendHello(socket: SocketLike): void {
const hello: RelayHelloFrame = {
v: 1,
kind: "hello",
clientType: "host",
protocol: 1,
...(this.options.sessionId ? { sessionId: this.options.sessionId } : {}),
...(this.options.lastSeq !== undefined ? { lastSeq: this.options.lastSeq } : {}),
};
socket.send(JSON.stringify(hello));
if (this.options.accessToken) {
// Keep authentication separate from hello so a relay can challenge the
// host before accepting a bearer token (and so hello remains cacheable).
socket.send(JSON.stringify({ v: 1, kind: "auth", accessToken: this.options.accessToken }));
}
}
private flush(socket: SocketLike): void {
if (socket.readyState !== OPEN || this.authenticatedSocket !== socket || this.socket !== socket) return;
while (this.queue.length > 0) {
const entry = this.queue.shift() as QueuedFrame;
this.queueBytes = Math.max(0, this.queueBytes - entry.bytes);
socket.send(entry.serialized);
}
}
private enqueue(entry: QueuedFrame): void {
// Transcript events are reconstructible: RelayHost publishes a fresh full
// session snapshot after every authenticated reconnect. Keep only the
// newest projection per session while preserving approval/command events.
if (entry.projectionKey) {
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
if (this.queue[index].projectionKey === entry.projectionKey) this.removeQueuedFrame(index);
}
}
if (entry.bytes > this.options.maxQueuedBytes) {
this.options.logger?.warn?.("Dropping relay frame that exceeds the reconnect queue byte limit");
return;
}
while (this.queue.length >= 100 || this.queueBytes + entry.bytes > this.options.maxQueuedBytes) {
const projectionIndex = this.queue.findIndex((queued) => Boolean(queued.projectionKey));
if (projectionIndex >= 0) {
this.removeQueuedFrame(projectionIndex);
continue;
}
// Never evict an approval/command solely to retain a transcript delta;
// the authoritative snapshot emitted after auth restores that state.
if (entry.projectionKey) {
this.options.logger?.debug?.("Dropping supersedable relay projection while reconnect queue is full");
return;
}
this.removeQueuedFrame(0);
}
this.queue.push(entry);
this.queueBytes += entry.bytes;
}
private removeQueuedFrame(index: number): void {
const [removed] = this.queue.splice(index, 1);
if (removed) this.queueBytes = Math.max(0, this.queueBytes - removed.bytes);
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.stopped) return;
const delay = this.retryMs;
this.retryMs = Math.min(this.options.reconnectMaxMs, Math.max(this.retryMs * 2, this.options.reconnectInitialMs));
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
void this.connect().catch((error) => this.options.logger?.debug?.("relay reconnect failed", error));
}, delay);
}
}
/**
* Line-oriented transport for local development and CI. Pipe it to a relay
* process with `node dist/cli.js`; each line is one JSON relay frame.
*/
export class StdioRelayTransport implements RelayTransport {
private readonly listeners = new Set<(frame: RelayFrame) => void>();
private readonly lineReader: ReadLineInterface;
private closed = false;
constructor(
private readonly input: NodeJS.ReadableStream = process.stdin,
private readonly output: NodeJS.WritableStream = process.stdout,
private readonly logger?: Logger,
) {
this.lineReader = createInterface({ input, crlfDelay: Infinity });
this.lineReader.on("line", (line) => {
if (!line.trim()) return;
try {
const frame = JSON.parse(line);
if (isRecord(frame)) for (const listener of this.listeners) listener(frame as RelayFrame);
} catch (error) {
this.logger?.warn?.("Ignoring malformed relay stdin frame", error);
}
});
}
async connect(): Promise<void> {
this.closed = false;
}
send(frame: RelayFrame): void {
if (this.closed) throw new Error("stdio relay transport is closed");
this.output.write(`${JSON.stringify(frame)}\n`);
}
onMessage(listener: (frame: RelayFrame) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
close(): void {
this.closed = true;
this.lineReader.close();
}
}
function bindSocket(
socket: SocketLike,
onOpen: () => void,
onMessage: (data: unknown) => void,
onError: (error: unknown) => void,
onClose: () => void,
): void {
if (typeof socket.on === "function") {
socket.on("open", onOpen);
socket.on("message", onMessage);
socket.on("error", onError);
socket.on("close", onClose);
} else if (typeof socket.addEventListener === "function") {
socket.addEventListener("open", onOpen);
socket.addEventListener("message", onMessage);
socket.addEventListener("error", onError);
socket.addEventListener("close", onClose);
} else {
onError(new Error("WebSocket implementation has no event API"));
}
}
function extractMessageData(raw: unknown): string {
if (typeof raw === "string") return raw;
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
if (isRecord(raw) && "data" in raw) return extractMessageData(raw.data);
return String(raw ?? "");
}
@@ -0,0 +1,650 @@
import { randomUUID } from "node:crypto";
import {
AgentAdapter,
AgentEvent,
approvalDecisionKind,
approvalDecisionKindForMethod,
asJsonObject,
asJsonValue,
Disposable,
hasApprovalDecisionField,
isRecord,
JsonObject,
JsonRpcId,
Logger,
JsonValue,
RelayActor,
RelayCommandFrame,
RelayEventFrame,
RelayFrame,
RelayTransport,
} from "./protocol";
export interface RelayHostOptions {
adapter: AgentAdapter;
relay: RelayTransport;
sessionId?: string;
actor?: RelayActor;
/** Capabilities enforced locally even when relay authorization is bypassed. */
capabilities?: Iterable<string>;
logger?: Logger;
/** Emit a handshake on transports that do not implement one themselves. */
sendHandshake?: boolean;
}
/**
* Maps relay commands to the app-server AgentAdapter and publishes normalized
* adapter events. This is the policy boundary for the VS Code host.
*/
export class RelayHost {
private readonly adapter: AgentAdapter;
private readonly relay: RelayTransport;
private readonly options: RelayHostOptions;
private readonly subscriptions: Disposable[] = [];
private readonly commandResults = new Map<string, RelayEventFrame>();
private readonly inFlightCommands = new Set<string>();
private readonly capabilities: Set<string>;
private eventSeq = 0;
private sessionId: string;
private started = false;
private adapterReady = false;
constructor(options: RelayHostOptions);
constructor(adapter: AgentAdapter, relay: RelayTransport, options?: Omit<RelayHostOptions, "adapter" | "relay">);
constructor(
optionsOrAdapter: RelayHostOptions | AgentAdapter,
relayArg?: RelayTransport,
legacyOptions: Omit<RelayHostOptions, "adapter" | "relay"> = {},
) {
if (isAgentAdapter(optionsOrAdapter)) {
this.adapter = optionsOrAdapter;
if (!relayArg) throw new Error("RelayHost requires a relay transport");
this.relay = relayArg;
this.options = { ...legacyOptions, adapter: this.adapter, relay: this.relay };
} else {
this.options = optionsOrAdapter;
this.adapter = optionsOrAdapter.adapter;
this.relay = optionsOrAdapter.relay;
}
this.capabilities = new Set(this.options.capabilities ?? [
"read_output",
"send_task_input",
"cancel_task",
"approve_low_risk",
]);
this.sessionId = this.options.sessionId ?? `sess_${randomUUID()}`;
}
get id(): string {
return this.sessionId;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
this.subscriptions.push(this.adapter.onEvent((event) => {
if (event.type === "connection.opened") this.adapterReady = true;
if (event.type === "connection.closed") this.adapterReady = false;
this.publishAgentEvent(event);
}));
this.subscriptions.push(this.relay.onMessage((frame) => {
void this.handleFrame(frame).catch((error) => {
this.options.logger?.warn?.("Invalid relay frame", error);
if (isRecord(frame) && typeof frame.commandId === "string") {
this.sendCommandResult(frame.commandId, false, undefined, error instanceof Error ? error.message : String(error), typeof frame.method === "string" ? frame.method : typeof frame.type === "string" ? frame.type : undefined);
}
});
}));
if (this.relay.onClose) this.subscriptions.push(this.relay.onClose((error) => {
// A relay disconnect must not leave an app-server request waiting for a
// browser that can no longer answer. The adapter's local deny path is
// deliberately fail-closed. Do not publish `connection.closed` here:
// that event describes the app-server process, while this callback only
// describes the outbound transport and is followed by connection.opened
// on a successful reconnect.
void this.adapter.denyPending?.("relay disconnected");
this.options.logger?.debug?.("Relay transport closed", error?.message ?? "");
}));
if (this.relay.onOpen) this.subscriptions.push(this.relay.onOpen(() => {
// RelayClient fires onOpen only after auth.ok. On reconnect the adapter
// is already initialized, so the synthetic event restores relay state;
// during initial startup the adapter event below is authoritative.
if (this.adapterReady) {
this.publishConnectionEvent("connection.opened");
void this.publishSnapshot();
}
}));
const configurableRelay = this.relay as RelayTransport & { setSessionId?: (sessionId: string) => void };
configurableRelay.setSessionId?.(this.sessionId);
try {
await this.relay.connect();
if (this.options.sendHandshake !== false && !transportHandlesHandshake(this.relay)) {
this.safeSend({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: this.sessionId });
}
// Start app-server only after the relay handshake is queued/sent. This
// keeps standalone stdout frames protocol-ordered and prevents an early
// notification from racing the host hello.
await this.adapter.start();
if (!this.adapterReady) {
this.adapterReady = true;
this.publishConnectionEvent("connection.opened");
}
await this.publishSnapshot();
} catch (error) {
this.started = false;
this.adapterReady = false;
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose().catch(() => undefined);
throw error;
}
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
this.adapterReady = false;
this.inFlightCommands.clear();
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose();
}
/** Public for unit tests and local stdin bridges. */
async handleFrame(frame: RelayFrame): Promise<void> {
if (!isRecord(frame)) return;
if (frame.kind === "command" || isCommandLike(frame)) {
await this.handleCommand(frame as unknown as RelayCommandFrame);
return;
}
if (frame.kind === "event" && typeof frame.seq === "number") {
this.safeSend({ v: 1, kind: "ack", sessionId: frame.sessionId, seq: frame.seq });
}
}
private async handleCommand(frame: RelayCommandFrame): Promise<void> {
const command = normalizeCommand(frame);
const commandId = command.commandId;
if (commandId) {
const previous = this.commandResults.get(commandId);
if (previous) {
this.safeSend(previous);
return;
}
if (this.inFlightCommands.has(commandId)) {
this.safeSend({
v: 1,
kind: "event",
// Do not call this `command.accepted`: the relay treats that event
// as the terminal result for its pending command. A retry while the
// original operation is running is only an informational event.
type: "command.pending",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: { commandId, duplicate: true, pending: true },
});
return;
}
this.inFlightCommands.add(commandId);
}
const role = frame.actor?.role ?? "operator";
const denied = authorize(command.type, role, this.capabilities);
if (denied) {
// A viewer may not force a pending approval to deny (that would turn a
// read-only role into a denial-of-service primitive). Authorized roles
// can still be rejected by local capability/policy checks, in which
// case denying the app-server request is the safe terminal action.
if (role === "owner" || role === "operator" || role === "approver" || role === "host") {
await this.denyApprovalIfNeeded(command.type, command.payload, denied);
}
this.sendCommandResult(commandId, false, undefined, denied, command.type);
if (commandId) this.inFlightCommands.delete(commandId);
return;
}
try {
const result = await this.executeCommand(command.type, command.payload);
this.sendCommandResult(commandId, true, result, undefined, command.type);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.logger?.warn?.(`Relay command ${command.type} failed`, error);
await this.denyApprovalIfNeeded(command.type, command.payload, message);
this.sendCommandResult(commandId, false, undefined, message, command.type);
} finally {
if (commandId) this.inFlightCommands.delete(commandId);
}
}
private async denyApprovalIfNeeded(type: string, payload: JsonObject, reason: string): Promise<void> {
const command = canonicalCommandType(type);
if (command !== "approval.respond" && command !== "input.respond" && command !== "server.request.respond") return;
const requestId = payload.requestId;
if (requestId === undefined || (typeof requestId !== "string" && typeof requestId !== "number")) return;
try {
await this.adapter.respondApproval(requestId, "deny", reason);
} catch {
// The request may already have expired or been resolved. Keep the
// original command rejection as the observable result.
}
}
private async executeCommand(type: string, payload: JsonObject): Promise<unknown> {
switch (canonicalCommandType(type)) {
case "control.mode.get": {
const mode = this.adapter.getControlMode?.();
if (mode) return { mode };
const snapshot = await this.adapter.snapshot();
const controlMode = snapshot.metadata?.controlMode;
if (controlMode !== "sync" && controlMode !== "async") {
throw new Error("adapter does not expose a control mode");
}
return { mode: controlMode };
}
case "control.mode.set":
if (!this.adapter.setControlMode) throw new Error("adapter does not support control mode switching");
return this.adapter.setControlMode(payload);
case "thread.start":
if (!this.adapter.startThread) throw new Error("adapter does not support thread/start");
return this.adapter.startThread(payload);
case "session.new":
if (!this.adapter.newSession) throw new Error("adapter does not support session/new");
return this.adapter.newSession(payload);
case "thread.settings.update":
if (!this.adapter.updateThreadSettings) throw new Error("adapter does not support thread/settings/update");
return this.adapter.updateThreadSettings(payload);
case "session.list":
if (!this.adapter.listSessions) throw new Error("adapter does not support session/list");
return this.adapter.listSessions(payload);
case "session.select":
if (!this.adapter.selectSession) throw new Error("adapter does not support session/select");
return this.adapter.selectSession(payload);
case "turn.start":
if (this.adapter.startTurn) return this.adapter.startTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.steer":
if (this.adapter.steerTurn) return this.adapter.steerTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.interrupt":
if (this.adapter.interruptTurn) return this.adapter.interruptTurn(payload);
return this.adapter.cancel(typeof payload.turnId === "string" ? payload.turnId : undefined, payload);
case "task.input": {
const text = typeof payload.text === "string" ? payload.text : typeof payload.message === "string" ? payload.message : undefined;
if (!text) throw new Error("task.input requires payload.text");
return this.adapter.sendInput(text, payload);
}
case "task.cancel": {
const taskId = typeof payload.taskId === "string" ? payload.taskId : typeof payload.turnId === "string" ? payload.turnId : undefined;
return this.adapter.cancel(taskId, payload);
}
case "approval.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error("approval.respond requires requestId");
const requestedValue = payload.decision;
const decision = approvalDecisionKind(requestedValue);
if (!decision) throw new Error("decision must be a recognized allow, deny, or cancel value");
const snapshot = await this.adapter.snapshot();
// JSON-RPC distinguishes numeric and string ids. Keep the lookup
// type-safe so id `1` cannot accidentally authorize response `"1"`.
const approval = snapshot.pendingApprovals.find((item) => item.requestId === requestId);
const method = approval?.method ?? (typeof payload.method === "string" ? payload.method : undefined);
const response = payload.response ?? implicitApprovalResponse(requestedValue, decision, method, payload.scope);
if (decision === "allow") {
if (approval && typeof payload.commandHash === "string" && payload.commandHash !== approval.commandHash) {
throw new Error("approval commandHash does not match the pending request");
}
if (approval?.risk === "high" && !this.capabilities.has("approve_high_risk") && !this.capabilities.has("*")) {
throw new Error("host policy requires approve_high_risk for this approval");
}
}
validateApprovalResponse(decision, response, method);
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
response,
);
}
case "input.respond":
case "server.request.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error(`${type} requires requestId`);
const response = payload.response ?? (payload.answers !== undefined ? payload.answers : undefined);
// Tool-input requests do not carry an allow/deny field in their wire
// response, while MCP elicitation uses `action`. Prefer an explicit
// response action when present; otherwise honor the relay decision and
// fail closed when a denial has no custom response.
if (response !== undefined && !isRecord(response)) {
throw new Error("input response must be a JSON object");
}
const responseDecision = isRecord(response)
? explicitResponseDecision(response)
: undefined;
const requestedDecision = payload.decision === undefined
? undefined
: approvalDecisionKind(payload.decision);
if (payload.decision !== undefined && !requestedDecision) {
throw new Error("decision must be allow, deny, or cancel");
}
if (responseDecision && requestedDecision
&& responseDecision !== requestedDecision
// RelayHost uses `decision: "allow"` as a generic envelope for
// MCP/input responses; the nested action remains authoritative in
// that one compatibility case.
&& requestedDecision !== "allow") {
throw new Error(`input response implies ${responseDecision}, but decision is ${requestedDecision}`);
}
// For MCP, `response.action` is the actual app-server decision and is
// authoritative even if a relay uses `decision: "allow"` as a generic
// input-response envelope. With no custom response, an explicit relay
// decision (or the fail-closed deny default) controls the result.
const decision = responseDecision ?? requestedDecision ?? (response === undefined ? "deny" : "allow");
const responseForAdapter = requestedDecision && requestedDecision !== "allow" && !responseDecision
? undefined
: response;
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
responseForAdapter,
);
}
case "session.snapshot":
case "snapshot":
return this.adapter.snapshot();
case "ping":
return { pong: true, ts: new Date().toISOString() };
default:
throw new Error(`unsupported relay command: ${type}`);
}
}
private sendCommandResult(commandId: string | undefined, ok: boolean, result?: unknown, error?: string, method?: string): void {
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: ok ? "command.accepted" : "command.rejected",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: {
...(commandId ? { commandId } : {}),
...(method ? { method } : {}),
ok,
...(ok ? { result: asJsonValue(result) } : { error: error ?? "command rejected" }),
},
};
if (commandId) {
this.commandResults.set(commandId, frame);
if (this.commandResults.size > 1000) this.commandResults.delete(this.commandResults.keys().next().value as string);
}
this.safeSend(frame);
}
private publishAgentEvent(event: AgentEvent): void {
if (event.threadId && this.sessionId.startsWith("sess_")) {
// Keep a stable relay session id while exposing the app-server thread id
// in the payload; a relay session may contain more than one thread.
}
const payload: JsonObject = {
...event.payload,
...(event.status ? { executionStatus: asJsonValue(event.status) } : {}),
...(event.threadId ? { threadId: event.threadId } : {}),
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.requestId !== undefined ? { requestId: asJsonValue(event.requestId) } : {}),
...(event.raw !== undefined ? { raw: event.raw } : {}),
};
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: event.type,
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload,
...(event.status ? { status: { ...event.status, activeFlags: [...event.status.activeFlags] } } : {}),
};
try {
this.safeSend(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to publish relay event", error);
}
}
private safeSend(frame: RelayFrame): void {
try {
this.relay.send(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to send relay frame", error);
}
}
private publishConnectionEvent(type: string, error?: Error): void {
if (!this.started && type === "connection.closed") return;
this.publishAgentEvent({ type, payload: error ? { message: error.message } : {} });
}
private async publishSnapshot(): Promise<void> {
try {
const snapshot = await this.adapter.snapshot();
this.publishAgentEvent({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
} catch (error) {
this.options.logger?.warn?.("Unable to publish adapter snapshot", error);
}
}
}
interface NormalizedCommand {
type: string;
commandId?: string;
payload: JsonObject;
}
function normalizeCommand(frame: RelayCommandFrame): NormalizedCommand {
const nested = isRecord(frame.command) ? frame.command : undefined;
const type = typeof nested?.type === "string"
? nested.type
: typeof frame.method === "string"
? frame.method
: frame.type === "command"
? ""
: frame.type;
const commandId = typeof frame.commandId === "string"
? frame.commandId
: typeof nested?.commandId === "string"
? nested.commandId
: typeof frame.id === "string"
? frame.id
: undefined;
if (!type) throw new Error("relay command has no type");
if (isRecord(nested?.payload)) return { type, commandId, payload: asJsonObject(nested.payload) };
if (isRecord(frame.payload)) return { type, commandId, payload: asJsonObject(frame.payload) };
if (isRecord(frame.params)) return { type, commandId, payload: asJsonObject(frame.params) };
const payload: JsonObject = {};
for (const [key, value] of Object.entries(frame)) {
if (["v", "kind", "type", "method", "params", "commandId", "id", "sessionId", "actor", "command"].includes(key)) continue;
if (value !== undefined) payload[key] = asJsonValue(value);
}
return { type, commandId, payload };
}
function canonicalCommandType(type: string): string {
const normalized = type.trim().replace(/\//g, ".").replace(/\s+/g, ".").toLowerCase();
if (normalized === "control.mode.get" || normalized === "controlmode.get" || normalized === "controlmodeget" || normalized === "mode.get" || normalized === "modeget") return "control.mode.get";
if (normalized === "control.mode.set" || normalized === "controlmode.set" || normalized === "controlmodeset" || normalized === "mode.set" || normalized === "modeset") return "control.mode.set";
if (normalized === "thread.start" || normalized === "threadstart") return "thread.start";
if (normalized === "session.new" || normalized === "sessionnew" || normalized === "thread.new" || normalized === "threadnew") return "session.new";
if (normalized === "thread.settings.update" || normalized === "threadsettings.update" || normalized === "threadsettingsupdate") return "thread.settings.update";
if (normalized === "session.list" || normalized === "thread.list" || normalized === "sessionlist" || normalized === "threadlist") return "session.list";
if (normalized === "session.select" || normalized === "session.switch" || normalized === "thread.select" || normalized === "thread.attach" || normalized === "sessionswitch" || normalized === "threadselect") return "session.select";
if (normalized === "turn.start" || normalized === "turnstart") return "turn.start";
if (normalized === "turn.steer" || normalized === "turnsteer") return "turn.steer";
if (normalized === "turn.interrupt" || normalized === "turninterrupt") return "turn.interrupt";
if (normalized === "approval.respond" || normalized === "approvalrespond") return "approval.respond";
if (normalized === "task.input" || normalized === "taskinput") return "task.input";
if (normalized === "task.cancel" || normalized === "taskcancel") return "task.cancel";
if (normalized === "input.respond" || normalized === "inputrespond") return "input.respond";
if (normalized === "server.request.respond" || normalized === "serverrequest.respond") return "server.request.respond";
if (normalized === "session.snapshot" || normalized === "snapshot") return "session.snapshot";
return normalized;
}
function authorize(type: string, role: string, capabilities: Set<string>): string | undefined {
const command = canonicalCommandType(type);
const readOnly = command === "session.snapshot" || command === "snapshot" || command === "session.list" || command === "control.mode.get" || command === "ping";
if (readOnly) return undefined;
if (role === "viewer") return "viewer role cannot issue control commands";
if (role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") return `role ${role} is not authorized`;
if ((command === "approval.respond" || command === "input.respond" || command === "server.request.respond") && role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") {
return "role is not authorized to resolve approvals";
}
const required = command === "approval.respond" ? "approve_low_risk" : command === "task.cancel" || command === "turn.interrupt" ? "cancel_task" : command === "task.input" || command.startsWith("turn.") || command === "thread.start" || command === "thread.settings.update" || command === "session.select" || command === "session.new" || command === "control.mode.set" ? "send_task_input" : undefined;
if (required && !capabilities.has(required) && !capabilities.has("*") && role !== "owner" && role !== "host") return `missing capability: ${required}`;
return undefined;
}
function isCommandLike(frame: Record<string, unknown>): boolean {
if (frame.kind === "command") return true;
if (frame.type === "command" && typeof frame.method === "string") return true;
if (frame.kind !== undefined) return false;
if (typeof frame.method === "string") return true;
if (typeof frame.commandId !== "string") return false;
return KNOWN_COMMAND_TYPES.has(String(frame.type).trim().replace(/\//g, ".").toLowerCase());
}
const KNOWN_COMMAND_TYPES = new Set([
"control.mode.get",
"control.mode.set",
"thread.start",
"session.new",
"thread.settings.update",
"session.list",
"session.select",
"turn.start",
"turn.steer",
"turn.interrupt",
"approval.respond",
"task.input",
"task.cancel",
"input.respond",
"server.request.respond",
"session.snapshot",
"snapshot",
"ping",
]);
function isAgentAdapter(value: unknown): value is AgentAdapter {
return isRecord(value) && typeof value.start === "function" && typeof value.onEvent === "function" && typeof value.sendInput === "function" && typeof value.cancel === "function" && typeof value.respondApproval === "function" && typeof value.snapshot === "function";
}
function extractCommandText(payload: JsonObject): string {
if (typeof payload.text === "string") return payload.text;
if (typeof payload.message === "string") return payload.message;
if (typeof payload.prompt === "string") return payload.prompt;
if (Array.isArray(payload.input)) {
const first = payload.input[0];
if (isRecord(first) && typeof first.text === "string") return first.text;
}
throw new Error("turn command requires text or input");
}
function validateApprovalResponse(
decision: "allow" | "deny" | "cancel",
response: JsonValue | undefined,
method?: string,
): void {
if (response === undefined) return;
if (!isRecord(response)) throw new Error("approval response must be a JSON object");
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
if (hasDecision || hasAction) {
const decisionKind = hasDecision ? approvalDecisionKindForMethod(response.decision, method) : undefined;
const actionKind = hasAction ? approvalDecisionKindForMethod(response.action, method) : undefined;
if (hasDecision && !decisionKind) throw new Error("unsupported approval response decision");
if (hasAction && !actionKind) throw new Error("unsupported approval response action");
if (decisionKind && actionKind && decisionKind !== actionKind) {
throw new Error("approval response decision and action conflict");
}
const implied = decisionKind ?? actionKind;
if (implied && implied !== decision) {
throw new Error(`approval response implies ${implied}, but decision is ${decision}`);
}
return;
}
// Permissions approvals intentionally carry a profile rather than a
// decision field. Keep the profile shape narrow; malformed/unknown objects
// must not be interpreted as an approval.
if (method === "item/permissions/requestApproval"
&& isRecord(response.permissions)
&& (response.scope === "turn" || response.scope === "session")
&& (response.strictAutoReview === undefined || typeof response.strictAutoReview === "boolean")
&& Object.keys(response).every((key) => key === "permissions" || key === "scope" || key === "strictAutoReview")) {
return;
}
throw new Error("approval response has no recognized decision or permission profile");
}
/**
* Convert a relay's compact outer decision into a wire response only when it
* carries a non-canonical app-server value. Canonical `allow`/`deny`/`cancel`
* remain undefined so the adapter can choose the method-specific default.
*/
function implicitApprovalResponse(
requestedValue: JsonValue,
decision: "allow" | "deny" | "cancel",
method?: string,
scope?: JsonValue,
): JsonValue | undefined {
// Permission approvals have a profile response, not a decision wrapper.
// Let the adapter construct the requested turn-scoped profile by default;
// callers that need session scope must provide the full profile explicitly.
if (method === "item/permissions/requestApproval") return undefined;
if (requestedValue === "allow" || requestedValue === "deny" || requestedValue === "cancel") {
if (requestedValue === "allow" && scope === "session") {
if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved_for_session" };
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "acceptForSession" };
}
return undefined;
}
// The generic classifier has already rejected unknown/conflicting values.
// Preserve recognized legacy/v2 tags exactly under the app-server wrapper.
if (decision === "allow" || decision === "deny" || decision === "cancel") {
return { decision: requestedValue };
}
return undefined;
}
function explicitResponseDecision(response: Record<string, unknown>): "allow" | "deny" | "cancel" | undefined {
if (!hasApprovalDecisionField(response)) return undefined;
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
const decision = hasDecision ? approvalDecisionKind(response.decision) : undefined;
const action = hasAction ? approvalDecisionKind(response.action) : undefined;
if (hasDecision && !decision) throw new Error("unsupported input response decision");
if (hasAction && !action) throw new Error("unsupported input response action");
if (decision && action && decision !== action) throw new Error("input response decision and action conflict");
return decision ?? action;
}
function transportHandlesHandshake(transport: RelayTransport): boolean {
return Boolean((transport as RelayTransport & { handlesHandshake?: boolean }).handlesHandshake);
}
@@ -0,0 +1,425 @@
import {
AgentAdapter,
AgentEvent,
asJsonObject,
ControlMode,
Disposable,
JsonObject,
JsonRpcId,
JsonValue,
Logger,
SessionSnapshot,
} from "./protocol";
export type AgentAdapterFactory = (mode: ControlMode) => AgentAdapter | Promise<AgentAdapter>;
export interface SwitchableAgentAdapterOptions {
initialMode: ControlMode;
createAdapter: AgentAdapterFactory;
/** Persist the committed mode. Persistence errors do not roll back a live adapter. */
onModeChanged?: (mode: ControlMode, previousMode: ControlMode) => void | Promise<void>;
logger?: Logger;
}
interface AdapterBinding {
adapter: AgentAdapter;
mode: ControlMode;
generation: number;
committed: boolean;
bufferedEvents: AgentEvent[];
subscription: Disposable;
}
interface ModeCapabilities extends JsonObject {
followsVscodeRoute: boolean;
sessionList: boolean;
sessionSelect: boolean;
sessionCreate: boolean;
threadSettings: boolean;
}
/**
* Keeps RelayHost bound to one stable AgentAdapter while atomically replacing
* the implementation behind it when the control owner changes.
*/
export class SwitchableAgentAdapter implements AgentAdapter {
private readonly options: SwitchableAgentAdapterOptions;
private readonly listeners = new Set<(event: AgentEvent) => void>();
private binding: AdapterBinding | null = null;
private controlMode: ControlMode;
private modeEpoch = 0;
private startPromise: Promise<void> | null = null;
private switchPromise: Promise<JsonValue> | null = null;
private started = false;
private disposed = false;
constructor(options: SwitchableAgentAdapterOptions) {
this.options = options;
this.controlMode = validateControlMode(options.initialMode);
}
async start(): Promise<void> {
if (this.started) return;
if (this.disposed) throw new Error("switchable adapter has been disposed");
if (this.startPromise) return this.startPromise;
const operation = this.startInitialAdapter();
this.startPromise = operation;
try {
await operation;
} finally {
if (this.startPromise === operation) this.startPromise = null;
}
}
getControlMode(): ControlMode {
return this.controlMode;
}
async setControlMode(params: JsonObject): Promise<JsonValue> {
const nextMode = controlModeFromParams(params);
this.ensureStarted();
if (this.switchPromise) throw new Error("a control mode switch is already in progress");
if (nextMode === this.controlMode) {
return {
changed: false,
controlMode: this.controlMode,
previousControlMode: this.controlMode,
modeEpoch: this.modeEpoch,
};
}
const operation = this.performModeSwitch(nextMode);
this.switchPromise = operation;
try {
return await operation;
} finally {
if (this.switchPromise === operation) this.switchPromise = null;
}
}
async startThread(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("thread/start");
const adapter = this.activeAdapterForMutation();
if (!adapter.startThread) throw unsupported("thread/start", this.controlMode);
return adapter.startThread(params);
}
async newSession(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("session/new");
const adapter = this.activeAdapterForMutation();
if (adapter.newSession) return adapter.newSession(params);
if (adapter.startThread) return adapter.startThread(params);
throw unsupported("session/new", this.controlMode);
}
async startTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.startTurn) throw unsupported("turn/start", this.controlMode);
return adapter.startTurn(params);
}
async steerTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.steerTurn) throw unsupported("turn/steer", this.controlMode);
return adapter.steerTurn(params);
}
async updateThreadSettings(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapterForMutation();
if (!adapter.updateThreadSettings) throw unsupported("thread/settings/update", this.controlMode);
return adapter.updateThreadSettings(params);
}
async listSessions(params: JsonObject = {}): Promise<JsonValue> {
this.assertIndependentNavigation("session/list");
const adapter = this.activeAdapter();
if (!adapter.listSessions) throw unsupported("session/list", this.controlMode);
return adapter.listSessions(params);
}
async selectSession(params: JsonObject): Promise<JsonValue> {
this.assertIndependentNavigation("session/select");
const adapter = this.activeAdapterForMutation();
if (!adapter.selectSession) throw unsupported("session/select", this.controlMode);
return adapter.selectSession(params);
}
async interruptTurn(params: JsonObject): Promise<JsonValue> {
const adapter = this.activeAdapter();
if (!adapter.interruptTurn) throw unsupported("turn/interrupt", this.controlMode);
return adapter.interruptTurn(params);
}
async sendInput(text: string, params: JsonObject = {}): Promise<JsonValue> {
return this.activeAdapterForMutation().sendInput(text, params);
}
async cancel(taskId?: string, params: JsonObject = {}): Promise<JsonValue> {
return this.activeAdapter().cancel(taskId, params);
}
async respondApproval(
requestId: JsonRpcId,
decision: "allow" | "deny" | "cancel",
reason?: string,
response?: JsonValue,
): Promise<JsonValue> {
return this.activeAdapter().respondApproval(requestId, decision, reason, response);
}
async denyPending(reason?: string): Promise<void> {
await this.activeAdapter().denyPending?.(reason);
}
async snapshot(): Promise<SessionSnapshot> {
const binding = this.activeBinding();
const snapshot = await binding.adapter.snapshot();
return this.decorateSnapshot(snapshot, binding);
}
onEvent(listener: (event: AgentEvent) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
const starting = this.startPromise;
const switching = this.switchPromise;
await starting?.catch(() => undefined);
await switching?.catch(() => undefined);
const binding = this.binding;
this.binding = null;
this.started = false;
if (!binding) return;
binding.committed = false;
binding.subscription.dispose();
await binding.adapter.dispose();
}
private async startInitialAdapter(): Promise<void> {
const binding = await this.createBinding(this.controlMode, this.modeEpoch);
try {
await binding.adapter.start();
if (this.disposed) throw new Error("switchable adapter was disposed while starting");
binding.committed = true;
this.binding = binding;
this.started = true;
this.flushBufferedEvents(binding);
} catch (error) {
await this.releaseBinding(binding);
throw error;
}
}
private async performModeSwitch(nextMode: ControlMode): Promise<JsonValue> {
const previousBinding = this.activeBinding();
const previousMode = this.controlMode;
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
const nextEpoch = this.modeEpoch + 1;
const candidate = await this.createBinding(nextMode, nextEpoch);
if (candidate.adapter === previousBinding.adapter) {
candidate.subscription.dispose();
throw new Error("adapter factory must return a distinct adapter when switching control modes");
}
try {
await candidate.adapter.start();
if (this.disposed) throw new Error("switchable adapter was disposed while switching modes");
// VS Code can start a turn independently while the candidate boots.
// Recheck immediately before the synchronous commit point.
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
const candidateSnapshot = await candidate.adapter.snapshot();
// The candidate snapshot is an await point, so make the old adapter's
// liveness check the final operation before committing synchronously.
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
candidate.committed = true;
this.binding = candidate;
this.controlMode = nextMode;
this.modeEpoch = nextEpoch;
previousBinding.committed = false;
const result: JsonObject = {
changed: true,
controlMode: nextMode,
previousControlMode: previousMode,
modeEpoch: nextEpoch,
};
this.emit({ type: "control.mode.changed", payload: result });
this.flushBufferedEvents(candidate);
const snapshot = this.decorateSnapshot(candidateSnapshot, candidate);
this.emit({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
previousBinding.subscription.dispose();
await previousBinding.adapter.dispose().catch((error) => {
this.options.logger?.warn?.("Unable to dispose the previous control mode adapter", error);
});
await Promise.resolve(this.options.onModeChanged?.(nextMode, previousMode)).catch((error) => {
this.options.logger?.warn?.("Unable to persist the committed control mode", error);
});
return result;
} catch (error) {
if (this.binding !== candidate) await this.releaseBinding(candidate);
throw error;
}
}
private async createBinding(mode: ControlMode, generation: number): Promise<AdapterBinding> {
const adapter = await this.options.createAdapter(mode);
if (!adapter) throw new Error(`adapter factory returned no adapter for ${mode} mode`);
const binding: AdapterBinding = {
adapter,
mode,
generation,
committed: false,
bufferedEvents: [],
subscription: { dispose: () => undefined },
};
binding.subscription = adapter.onEvent((event) => this.receiveAdapterEvent(binding, event));
return binding;
}
private receiveAdapterEvent(binding: AdapterBinding, event: AgentEvent): void {
if (!binding.committed) {
binding.bufferedEvents.push(event);
return;
}
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
this.emit(this.decorateEvent(event, binding));
}
private flushBufferedEvents(binding: AdapterBinding): void {
const events = binding.bufferedEvents.splice(0);
for (const event of events) {
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
this.emit(this.decorateEvent(event, binding));
}
}
private emit(event: AgentEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch (error) {
this.options.logger?.warn?.("Switchable adapter event listener failed", error);
}
}
}
private activeBinding(): AdapterBinding {
this.ensureStarted();
if (!this.binding) throw new Error("switchable adapter has no active adapter");
return this.binding;
}
private activeAdapter(): AgentAdapter {
return this.activeBinding().adapter;
}
private activeAdapterForMutation(): AgentAdapter {
if (this.switchPromise) throw new Error("control mode is switching; retry after it completes");
return this.activeAdapter();
}
private ensureStarted(): void {
if (this.disposed) throw new Error("switchable adapter has been disposed");
if (!this.started || !this.binding) throw new Error("switchable adapter is not started");
}
private assertIndependentNavigation(operation: string): void {
if (this.controlMode === "sync") {
throw new Error(`${operation} is unavailable in sync mode; conversation navigation follows VS Code`);
}
}
private assertSnapshotIdle(snapshot: SessionSnapshot): void {
const pendingApprovalCount = snapshot.pendingApprovals.length;
const pendingRequestCount = snapshot.pendingRequests?.length ?? 0;
const state = normalizeStatus(snapshot.state);
const turnStatus = normalizeStatus(snapshot.status?.turnStatus ?? snapshot.turnStatus ?? "");
const activeFlags = snapshot.status?.activeFlags ?? snapshot.activeFlags ?? [];
const hasActiveState = ACTIVE_STATUSES.has(state) || ACTIVE_STATUSES.has(turnStatus) || activeFlags.length > 0;
if (snapshot.turnId || hasActiveState || pendingApprovalCount > 0 || pendingRequestCount > 0) {
throw new Error("cannot switch control mode while a turn or request is active");
}
}
private decorateSnapshot(snapshot: SessionSnapshot, binding: AdapterBinding): SessionSnapshot {
return {
...snapshot,
metadata: {
...(snapshot.metadata ?? {}),
mode: binding.mode,
controlMode: binding.mode,
modeEpoch: binding.generation,
capabilities: this.capabilities(binding),
},
};
}
private decorateEvent(event: AgentEvent, binding: AdapterBinding): AgentEvent {
if (event.type !== "session.snapshot") return event;
return {
...event,
payload: {
...event.payload,
metadata: {
...asJsonObject(event.payload.metadata),
mode: binding.mode,
controlMode: binding.mode,
modeEpoch: binding.generation,
capabilities: this.capabilities(binding),
},
},
};
}
private capabilities(binding: AdapterBinding): ModeCapabilities {
const independent = binding.mode === "async";
return {
followsVscodeRoute: !independent,
sessionList: independent && typeof binding.adapter.listSessions === "function",
sessionSelect: independent && typeof binding.adapter.selectSession === "function",
sessionCreate: independent && (typeof binding.adapter.newSession === "function"
|| typeof binding.adapter.startThread === "function"),
threadSettings: typeof binding.adapter.updateThreadSettings === "function",
};
}
private async releaseBinding(binding: AdapterBinding): Promise<void> {
binding.committed = false;
binding.subscription.dispose();
await binding.adapter.dispose().catch(() => undefined);
}
}
function controlModeFromParams(params: JsonObject): ControlMode {
return validateControlMode(params.mode ?? params.controlMode);
}
function validateControlMode(value: unknown): ControlMode {
if (value === "sync" || value === "async") return value;
throw new Error("control mode must be sync or async");
}
function unsupported(operation: string, mode: ControlMode): Error {
return new Error(`${operation} is not supported by the ${mode} adapter`);
}
const ACTIVE_STATUSES = new Set(["active", "inprogress", "running", "starting", "thinking", "editing", "working"]);
function normalizeStatus(value: string): string {
return value.trim().replace(/[\s_-]+/g, "").toLowerCase();
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": [
"ES2022"
],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"sourceMap": true,
"skipLibCheck": true,
"types": [
"node",
"vscode"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>Codex</title>
</head>
<body class="codex-app local-no-auth">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@aether/vscodex-web",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"typecheck": "vue-tsc -b --pretty false",
"test": "vitest run"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.3",
"vue": "^3.5.20"
},
"devDependencies": {
"@types/node": "^24.3.0",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
},
"engines": {
"node": ">=20"
}
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import CodexSurface from "./components/CodexSurface.vue";
</script>
<template>
<CodexSurface />
</template>
@@ -0,0 +1,269 @@
<template>
<div class="codex-panel">
<div class="connection" aria-live="polite" hidden>
<span id="connectionDot" class="dot offline"></span>
<span id="connectionText">正在连接</span>
<span id="roleBadge" class="badge">未认证</span>
</div>
<main class="chat-shell">
<section class="chat-header" aria-label="当前会话">
<div class="thread-heading">
<button id="backButton" class="icon-button header-back-button" type="button" data-panel-action="back" title="返回会话列表" aria-label="返回会话列表" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.75 3.5 5.25 8l4.5 4.5M5.5 8h6.25" /></svg>
</button>
<button id="sessionPickerButton" class="thread-picker-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="打开会话历史" aria-label="打开会话历史" disabled>
<h2 id="threadTitle">Codex</h2>
</button>
<span id="appState" class="status-text" aria-live="polite">等待 VS Code 主机</span>
</div>
<div class="thread-actions">
<button class="icon-button" type="button" data-panel-action="menu" title="更多操作" aria-label="更多操作">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3" cy="8" r="1" /><circle cx="8" cy="8" r="1" /><circle cx="13" cy="8" r="1" /></svg>
</button>
<button id="historyButton" class="icon-button header-history-button" type="button" data-panel-action="history" title="会话历史" aria-label="会话历史" hidden>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></svg>
</button>
<button class="icon-button" type="button" data-panel-action="settings" title="设置" aria-label="设置">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.7 2h2.6l.4 1.6c.4.2.8.4 1.2.7l1.6-.6 1.3 2.2-1.2 1.1a5 5 0 0 1 0 1.4l1.2 1.1-1.3 2.2-1.6-.6c-.4.3-.8.5-1.2.7L9.3 14H6.7l-.4-1.6a5 5 0 0 1-1.2-.7l-1.6.6-1.3-2.2 1.2-1.1a5 5 0 0 1 0-1.4L2.2 6l1.3-2.2 1.6.6c.4-.3.8-.5 1.2-.7L6.7 2Z" /><circle cx="8" cy="8" r="1.7" /></svg>
</button>
<button id="newSessionButton" class="icon-button new-session-button" type="button" data-panel-action="new-session" title="创建新会话" aria-label="创建新会话" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.25 3.25h5.5a1.5 1.5 0 0 1 1.5 1.5v2.5" /><path d="M3.25 3.25v9.5h6" /><path d="m8.2 11.35 4.55-4.55 1.25 1.25-4.55 4.55-2 .5Z" /></svg>
</button>
</div>
</section>
<div id="panelMenu" class="panel-popover panel-menu" hidden>
<button type="button" data-menu-action="sessions" hidden>最近会话</button>
<button type="button" data-menu-action="clear">清空当前输出</button>
<button type="button" data-menu-action="refresh">重新同步</button>
<button type="button" data-menu-action="expand">展开面板</button>
<button type="button" data-menu-action="close">隐藏面板</button>
</div>
<div id="detailsPopover" class="panel-popover details-popover settings-popover" hidden role="dialog" aria-label="设置">
<div class="popover-title">设置</div>
<div class="settings-shortcuts">
<button type="button" data-settings-action="model"><span>模型与推理强度</span><span id="settingsModelValue">默认</span></button>
<button type="button" data-settings-action="permission"><span>修改权限</span><span id="settingsPermissionValue">工作区写入</span></button>
<label id="localeSetting" class="settings-locale">
<span>语言</span>
<select id="localeSelect" aria-label="语言">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
</label>
</div>
<div class="settings-divider"></div>
<div class="popover-subtitle">当前会话</div>
<dl>
<dt>工作区</dt><dd id="popoverCwd">-</dd>
<dt>模式</dt><dd id="popoverMode">本地模式</dd>
<dt>thread</dt><dd id="popoverThread">-</dd>
</dl>
</div>
<div id="sessionPicker" class="panel-popover session-picker" hidden role="dialog" aria-label="最近会话">
<div class="session-picker-header">
<span class="popover-title">最近会话</span>
<button id="sessionPickerRefresh" class="session-picker-refresh" type="button" title="刷新会话列表" aria-label="刷新会话列表">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5V2m0 0h-3m3 0-2.1 2.1A5 5 0 1 0 13 9" /></svg>
</button>
</div>
<div class="session-search">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.8" cy="6.8" r="3.8" /><path d="m9.7 9.7 3.2 3.2" /></svg>
<label class="sr-only" for="sessionSearchInput">搜索最近会话</label>
<input id="sessionSearchInput" type="search" autocomplete="off" spellcheck="false" placeholder="搜索最近会话" aria-label="搜索最近会话" aria-controls="sessionList" aria-expanded="false" />
<button id="sessionSearchClear" class="session-search-clear" type="button" title="清除搜索" aria-label="清除搜索" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" /></svg>
</button>
</div>
<div id="sessionPickerStatus" class="session-picker-status" role="status" aria-live="polite"></div>
<div id="sessionList" class="session-list" role="listbox" aria-label="可用会话" tabindex="0"></div>
</div>
<section class="chat-panel" aria-label="对话内容">
<div id="output" class="output chat-scroll" tabindex="0" aria-live="polite" aria-label="Codex 消息"></div>
<button id="scrollToBottom" class="scroll-to-bottom" type="button" aria-label="回到最新消息" aria-hidden="true" tabindex="-1">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v9M4.5 8.5 8 12l3.5-3.5" /></svg>
<span class="scroll-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div id="inlineRequests" class="inline-requests" aria-live="polite" aria-label="待处理的 Codex 请求"></div>
</section>
<section id="messageForm" class="composer" aria-label="发送消息">
<section id="subagentsPanel" class="subagents-panel" aria-label="子代理" hidden>
<button id="subagentsToggle" class="subagents-toggle" type="button" aria-expanded="false">
<span class="subagents-title">子代理</span>
<span id="subagentsCount" class="subagents-count"></span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
</button>
<div id="subagentsList" class="subagents-list"></div>
</section>
<div id="liveActivity" class="live-activity" role="status" aria-live="polite" hidden>
<span class="activity-spinner" aria-hidden="true"></span>
<span class="activity-label"></span>
<span class="activity-dots" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="activity-elapsed"></span>
</div>
<div class="composer-surface">
<div id="messageInput" class="composer-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="提交后续变更要求" spellcheck="true"></div>
<div class="composer-footer">
<div class="composer-hint">
<button id="composerPlusButton" class="composer-icon-button" type="button" aria-haspopup="menu" aria-expanded="false" title="添加文件及更多内容" aria-label="添加文件及更多内容">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
</button>
<div id="composerPlusMenu" class="composer-popover composer-plus-menu" role="menu" hidden>
<div class="composer-popover-heading">添加文件及更多内容</div>
<button type="button" role="menuitem" data-composer-action="attach">添加文件</button>
<button type="button" role="menuitem" data-composer-action="photo">添加照片</button>
<button type="button" role="menuitem" data-composer-action="workspace">添加工作区上下文</button>
<button type="button" role="menuitem" data-composer-action="web-search">网页搜索</button>
</div>
<input id="attachmentInput" type="file" accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h,image/*" multiple hidden />
<button id="permissionChip" class="permission-chip" type="button" aria-haspopup="menu" aria-expanded="false" title="修改权限" aria-label="修改权限">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 13 4v3.6c0 3-2 5.6-5 6.6-3-1-5-3.6-5-6.6V4l5-2.2Z" /><path d="m5.5 8 1.6 1.6L10.8 6" /></svg>
<span id="permissionLabel">工作区写入</span>
<svg class="permission-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
</button>
<div id="permissionMenu" class="composer-popover permission-menu" role="menu" aria-label="权限设置" hidden>
<div class="composer-popover-heading">修改权限</div>
<button type="button" role="menuitemradio" data-permission-mode="ask" aria-checked="false"><span>需要时询问</span><small>编辑外部文件和联网时始终询问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="auto" aria-checked="false"><span>由 Codex 审批</span><small>仅对可能不安全的操作询问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="full" aria-checked="false"><span>完全访问</span><small>不限制联网或文件访问</small></button>
<button type="button" role="menuitemradio" data-permission-mode="custom" aria-checked="false"><span>自定义</span><small>使用 config.toml 中的权限</small></button>
<button type="button" role="menuitemradio" data-permission-mode="readonly" aria-checked="false"><span>只读</span><small>仅查看文件,不修改工作区</small></button>
</div>
<div id="permissionConfirm" class="permission-confirm" role="dialog" aria-modal="true" aria-labelledby="permissionConfirmTitle" hidden>
<div id="permissionConfirmTitle" class="permission-confirm-title">确认完全访问</div>
<p>完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。</p>
<div class="permission-confirm-actions">
<button id="permissionConfirmCancel" type="button">取消</button>
<button id="permissionConfirmAccept" class="primary" type="button">确认</button>
</div>
</div>
<div id="usagePicker" class="usage-picker" hidden>
<button id="usageButton" class="usage-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="查看上下文用量" aria-label="查看上下文用量"><span id="usageRing" class="usage-ring" aria-hidden="true"><span id="usageLabel">0%</span></span></button>
<div id="usageMenu" class="composer-popover usage-menu" role="dialog" aria-label="上下文用量" hidden>
<div class="composer-popover-heading">上下文用量</div>
<div id="usageSummary" class="usage-summary">暂无用量数据</div>
<div class="usage-meter"><span id="usageMeterBar"></span></div>
<div id="usageDetails" class="usage-details"></div>
</div>
</div>
<span id="factApp" class="sr-only">-</span>
<span id="factClients" class="sr-only">-</span>
<span id="factRequests" class="sr-only">0</span>
</div>
<div class="composer-actions">
<div id="modelPicker" class="model-picker">
<button id="modelPickerButton" class="model-picker-button" type="button" aria-haspopup="menu" aria-expanded="false" title="切换模型与推理强度" hidden>
<span id="modelLabel" class="model-label"></span>
<span id="modelEffortLabel" class="model-effort-label"></span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
</button>
<div id="modelMenu" class="model-menu" role="menu" aria-label="模型与推理强度" hidden>
<div id="modelPowerView" class="model-power-view">
<div class="model-power-heading">
<span>推理强度</span>
<button id="modelAdvancedToggle" class="model-advanced-toggle" type="button">高级</button>
</div>
<div class="model-power-control">
<span class="model-power-label">更高效</span>
<input id="modelPowerSlider" class="model-power-slider" type="range" min="0" max="3" step="1" value="1" aria-label="强度" aria-describedby="modelPowerInstructions" />
<span class="model-power-label">更智能</span>
</div>
<div id="modelPowerValue" class="model-power-value"></div>
<span id="modelPowerInstructions" class="sr-only">使用左右方向键调整强度</span>
</div>
<div id="modelAdvancedView" class="model-advanced-view" hidden>
<div class="model-advanced-toolbar">
<button id="modelAdvancedBack" class="model-advanced-back" type="button" aria-label="返回模型强度">‹</button>
<span>模型与推理强度</span>
</div>
<div class="model-menu-heading">模型</div>
<div id="modelOptions" class="model-options" role="listbox" aria-label="模型"></div>
<div class="model-menu-heading effort-heading">推理强度</div>
<div id="effortOptions" class="effort-options" role="listbox" aria-label="推理强度"></div>
</div>
</div>
</div>
<button id="interruptButton" class="compact-action interrupt-action" type="button" disabled title="中断当前 turn" aria-label="中断当前 turn">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1" /></svg>
</button>
<button id="steerButton" class="primary compact-action steer-action" type="button" disabled title="发送后续指令" aria-label="发送后续指令">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
</button>
<button id="startTurnButton" class="primary send-button" type="button" disabled title="发送消息" aria-label="发送消息">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
</button>
</div>
</div>
</div>
<div class="mode-row">
<span class="connection-mode-label">
<svg class="mode-icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M5 13h6M8 11v2" /></svg>
<span id="modeLabel">本地模式</span>
</span>
<div id="controlModeSwitch" class="control-mode-switch" role="group" aria-label="控制模式" aria-busy="false" data-mode="sync" data-switching="false">
<button type="button" data-control-mode="sync" aria-pressed="true" title="同步模式跟随 VS Code 当前会话" disabled>同步</button>
<button type="button" data-control-mode="async" aria-pressed="false" title="异步模式可独立管理会话" disabled>异步</button>
</div>
</div>
</section>
</main>
</div>
<button id="restorePanel" class="restore-panel" type="button" hidden>显示 Codex</button>
<section class="compatibility-state" aria-hidden="true" hidden inert>
<details id="sessionSettings">
<summary>会话设置</summary>
<div class="settings-grid">
<label>工作目录<input id="cwdInput" type="text" /></label>
<label>模型<input id="modelInput" type="text" placeholder="留空使用默认模型" /></label>
<label>沙箱
<select id="sandboxInput">
<option value="workspace-write">workspace-write</option>
<option value="read-only">read-only</option>
<option value="danger-full-access">danger-full-access</option>
</select>
</label>
<label>审批策略
<select id="approvalInput">
<option value="on-request">on-request</option>
<option value="untrusted">untrusted</option>
<option value="never">never</option>
</select>
</label>
<button id="startThreadButton" class="secondary" type="button">启动新 thread</button>
<div class="ids">
<span>thread</span><code id="threadId">-</code>
<span>turn</span><code id="turnId">-</code>
</div>
</div>
</details>
<details id="connectionSettings">
<summary>连接设置</summary>
<label class="token-field">
<span id="tokenLabel">本机连接(无需 token)</span>
<input id="tokenInput" type="password" autocomplete="off" placeholder="本机模式无需填写;认证模式再填写" />
</label>
</details>
<span id="sessionMode">已附着当前会话</span>
<span id="latestSeq">seq -</span>
<span id="outputHint">等待连接</span>
<button id="clearOutputButton" type="button">清空对话</button>
<span id="lastEvent">-</span>
<details id="requestsPanel"><summary><span>授权与输入</span><span id="requestCount" class="badge warning">0</span></summary><div id="requests" class="requests empty">暂无待处理请求</div></details>
</section>
</template>
+52
View File
@@ -0,0 +1,52 @@
import { createApp } from "vue";
import appRuntimeUrl from "../../public/app.js?url";
import embedBridgeUrl from "../../public/embed-bridge.js?url";
import i18nRuntimeUrl from "../../public/i18n.js?url";
import "../../public/style.css";
import App from "./App.vue";
import { installRequestTemplate } from "./runtime/request-template";
type RuntimeAsset = {
id: string;
url: string;
};
const runtimeAssets: RuntimeAsset[] = [
{ id: "vscodex-i18n-runtime", url: i18nRuntimeUrl },
{ id: "vscodex-embed-bridge", url: embedBridgeUrl },
{ id: "vscodex-compat-runtime", url: appRuntimeUrl },
];
function loadRuntimeAsset(asset: RuntimeAsset): Promise<void> {
const existing = document.getElementById(asset.id) as HTMLScriptElement | null;
if (existing?.dataset.loaded === "true") return Promise.resolve();
return new Promise((resolve, reject) => {
const script = existing ?? document.createElement("script");
script.id = asset.id;
script.async = false;
script.src = asset.url;
script.addEventListener("load", () => {
script.dataset.loaded = "true";
resolve();
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Unable to load ${asset.id}`)), { once: true });
if (!existing) document.body.append(script);
});
}
async function startCompatibilityRuntime(): Promise<void> {
for (const asset of runtimeAssets) await loadRuntimeAsset(asset);
}
createApp(App).mount("#app");
installRequestTemplate();
void startCompatibilityRuntime().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
const status = document.getElementById("appState");
if (status) status.textContent = message;
document.body.dataset.runtimeError = "true";
console.error("Failed to start the Codex compatibility runtime", error);
});
@@ -0,0 +1,34 @@
export function installRequestTemplate(): HTMLTemplateElement {
const existing = document.getElementById("requestTemplate");
if (existing instanceof HTMLTemplateElement) return existing;
const template = document.createElement("template");
template.id = "requestTemplate";
template.innerHTML = `
<article class="request">
<div class="request-title"><span class="request-icon" aria-hidden="true">!</span><strong class="request-method"></strong><span class="request-risk"></span><span class="request-id"></span></div>
<p class="request-summary"></p>
<pre class="request-command"></pre>
<div class="request-questions"></div>
<label class="request-scope-wrap" hidden>
<span>授权范围</span>
<select class="request-scope">
<option value="turn">仅本次 turn</option>
<option value="session">当前会话</option>
</select>
</label>
<details class="request-details">
<summary>查看请求数据</summary>
<pre class="request-json"></pre>
</details>
<textarea class="request-response" rows="4" aria-label="JSON 响应"></textarea>
<div class="button-row request-actions">
<button class="primary request-allow">允许</button>
<button class="secondary request-deny">拒绝</button>
<button class="secondary request-send">发送 JSON</button>
</div>
</article>
`;
document.body.append(template);
return template;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface Window {
AetherVscodexEmbed?: {
active: boolean;
stop?: () => void;
};
VscodexI18n?: {
locale: () => string;
setLocale: (locale: string, options?: { persist?: boolean }) => string;
};
}
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import CodexSurface from "../src/components/CodexSurface.vue";
import { installRequestTemplate } from "../src/runtime/request-template";
afterEach(() => {
document.body.innerHTML = "";
});
describe("CodexSurface", () => {
it("mounts the compatibility shell expected by the existing runtime", () => {
const wrapper = mount(CodexSurface, { attachTo: document.body });
expect(wrapper.find("#output").exists()).toBe(true);
expect(wrapper.find("#messageInput").attributes("contenteditable")).toBe("true");
expect(wrapper.find("#sessionPicker").exists()).toBe(true);
expect(wrapper.find("#modelMenu").exists()).toBe(true);
expect(wrapper.find("#permissionMenu").exists()).toBe(true);
expect(wrapper.find("#requests").exists()).toBe(true);
expect(wrapper.find("#controlModeSwitch").attributes("data-mode")).toBe("sync");
const controlModes = wrapper.findAll("#controlModeSwitch [data-control-mode]");
expect(controlModes).toHaveLength(2);
expect(controlModes[0].attributes("aria-pressed")).toBe("true");
expect(controlModes.every((button) => button.attributes("disabled") !== undefined)).toBe(true);
wrapper.unmount();
});
it("keeps every compatibility element from the legacy shell", () => {
mount(CodexSurface, { attachTo: document.body });
installRequestTemplate();
const legacyHtml = readFileSync(resolve(process.cwd(), "../public/index.html"), "utf8");
const legacyDocument = new DOMParser().parseFromString(legacyHtml, "text/html");
const expected = [...legacyDocument.querySelectorAll<HTMLElement>("[id]")]
.map((element) => ({ id: element.id, tag: element.tagName, className: element.className }))
.sort((left, right) => left.id.localeCompare(right.id));
const actual = [...document.querySelectorAll<HTMLElement>("[id]")]
.filter((element) => element.id !== "app")
.map((element) => ({ id: element.id, tag: element.tagName, className: element.className }))
.sort((left, right) => left.id.localeCompare(right.id));
expect(actual).toEqual(expected);
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"jsx": "preserve",
"types": ["vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+45
View File
@@ -0,0 +1,45 @@
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
const relayTarget = "http://127.0.0.1:8787";
export default defineConfig({
// Relative assets let the same build run at the local relay root and under
// Aether's /aether-vscodex/ static subpath.
base: "./",
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
fs: {
allow: [fileURLToPath(new URL("..", import.meta.url))],
},
proxy: {
"/api": {
target: relayTarget,
changeOrigin: true,
},
"/ws": {
target: relayTarget.replace("http", "ws"),
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
assetsInlineLimit: 0,
},
test: {
environment: "jsdom",
include: ["tests/**/*.test.ts"],
},
});
-51
View File
@@ -1,51 +0,0 @@
# Alembic 配置文件
# 用于数据库版本化迁移
[alembic]
# 迁移脚本存放目录
script_location = alembic
# 模板文件
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# 时区(用于生成迁移文件的时间戳)
timezone = UTC
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
# Docker 环境中会从 DATABASE_URL 环境变量读取
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
# 日志配置
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-101
View File
@@ -1,101 +0,0 @@
"""
Alembic 环境配置
用于数据库迁移的运行时环境设置
"""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 加载 .env 文件(本地开发时需要)
try:
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
except ImportError:
pass
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
from src.models.database import Base
# Alembic Config 对象
config = context.config
# 从环境变量获取数据库 URL
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_password = os.getenv("DB_PASSWORD", "")
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "5432")
db_name = os.getenv("DB_NAME", "aether")
db_user = os.getenv("DB_USER", "postgres")
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
config.set_main_option("sqlalchemy.url", database_url)
# 配置日志
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 目标元数据(包含所有表定义)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""
离线模式运行迁移
在离线模式下,不需要连接数据库,
只生成 SQL 脚本
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
在线模式运行迁移
在线模式下,直接连接数据库执行迁移
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
# 根据模式选择运行方式
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-26
View File
@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""应用迁移:升级到新版本"""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
${downgrades if downgrades else "pass"}
-775
View File
@@ -1,775 +0,0 @@
"""Baseline migration - all tables consolidated
Revision ID: 20251210_baseline
Revises:
Create Date: 2024-12-10
This is the consolidated baseline migration that creates all tables from scratch.
Includes all schema changes up to circuit breaker v2.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision = "20251210_baseline"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create ENUM types (with IF NOT EXISTS for idempotency)
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
op.execute(
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
)
# ==================== users ====================
op.create_table(
"users",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column(
"role",
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
nullable=False,
server_default="user",
),
sa.Column("allowed_providers", sa.JSON, nullable=True),
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("model_capability_settings", sa.JSON, nullable=True),
sa.Column("quota_usd", sa.Float, nullable=True),
sa.Column("used_usd", sa.Float, server_default="0.0"),
sa.Column("total_usd", sa.Float, server_default="0.0"),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
)
# ==================== providers ====================
op.create_table(
"providers",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("website", sa.String(500), nullable=True),
sa.Column(
"billing_type",
postgresql.ENUM(
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
),
nullable=False,
server_default="pay_as_you_go",
),
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rpm_limit", sa.Integer, nullable=True),
sa.Column("rpm_used", sa.Integer, server_default="0"),
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_priority", sa.Integer, server_default="100"),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("concurrent_limit", sa.Integer, nullable=True),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== global_models ====================
op.create_table(
"global_models",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("icon_url", sa.String(500), nullable=True),
sa.Column("official_url", sa.String(500), nullable=True),
sa.Column("default_price_per_request", sa.Float, nullable=True),
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
sa.Column("supported_capabilities", sa.JSON, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== api_keys ====================
op.create_table(
"api_keys",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
sa.Column("key_encrypted", sa.Text, nullable=True),
sa.Column("name", sa.String(100), nullable=True),
sa.Column("total_requests", sa.Integer, server_default="0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
sa.Column("current_balance_usd", sa.Float, nullable=True),
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
sa.Column("allowed_providers", sa.JSON, nullable=True),
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("rate_limit", sa.Integer, server_default="100"),
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
sa.Column("force_capabilities", sa.JSON, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== provider_endpoints ====================
op.create_table(
"provider_endpoints",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("api_format", sa.String(50), nullable=False),
sa.Column("base_url", sa.String(500), nullable=False),
sa.Column("headers", sa.JSON, nullable=True),
sa.Column("timeout", sa.Integer, server_default="300"),
sa.Column("max_retries", sa.Integer, server_default="3"),
sa.Column("max_concurrent", sa.Integer, nullable=True),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("custom_path", sa.String(200), nullable=True),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
)
op.create_index(
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
)
# ==================== models ====================
op.create_table(
"models",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
),
sa.Column(
"global_model_id",
sa.String(36),
sa.ForeignKey("global_models.id"),
nullable=False,
index=True,
),
sa.Column("provider_model_name", sa.String(200), nullable=False),
sa.Column("price_per_request", sa.Float, nullable=True),
sa.Column("tiered_pricing", sa.JSON, nullable=True),
sa.Column("supports_vision", sa.Boolean, nullable=True),
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
sa.Column("supports_streaming", sa.Boolean, nullable=True),
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("is_available", sa.Boolean, server_default="true"),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
)
# ==================== model_mappings ====================
op.create_table(
"model_mappings",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("source_model", sa.String(200), nullable=False, index=True),
sa.Column(
"target_global_model_id",
sa.String(36),
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
),
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
)
# ==================== provider_api_keys ====================
op.create_table(
"provider_api_keys",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("api_key", sa.String(500), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
sa.Column("internal_priority", sa.Integer, server_default="50"),
sa.Column("global_priority", sa.Integer, nullable=True),
sa.Column("max_concurrent", sa.Integer, nullable=True),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("daily_limit", sa.Integer, nullable=True),
sa.Column("monthly_limit", sa.Integer, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("capabilities", sa.JSON, nullable=True),
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_429_type", sa.String(50), nullable=True),
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
sa.Column("adjustment_history", sa.JSON, nullable=True),
# Sliding window fields (replaces high_utilization_start)
sa.Column("utilization_samples", sa.JSON, nullable=True),
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("health_score", sa.Float, server_default="1.0"),
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
# Circuit breaker v2 fields
sa.Column("request_results_window", sa.JSON, nullable=True),
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
sa.Column("request_count", sa.Integer, server_default="0"),
sa.Column("success_count", sa.Integer, server_default="0"),
sa.Column("error_count", sa.Integer, server_default="0"),
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_msg", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== usage ====================
op.create_table(
"usage",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("provider", sa.String(100), nullable=False),
sa.Column("model", sa.String(100), nullable=False),
sa.Column("target_model", sa.String(100), nullable=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"provider_endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"provider_api_key_id",
sa.String(36),
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("input_tokens", sa.Integer, server_default="0"),
sa.Column("output_tokens", sa.Integer, server_default="0"),
sa.Column("total_tokens", sa.Integer, server_default="0"),
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
sa.Column("input_price_per_1m", sa.Float, nullable=True),
sa.Column("output_price_per_1m", sa.Float, nullable=True),
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
sa.Column("price_per_request", sa.Float, nullable=True),
sa.Column("request_type", sa.String(50), nullable=True),
sa.Column("api_format", sa.String(50), nullable=True),
sa.Column("is_stream", sa.Boolean, server_default="false"),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("response_time_ms", sa.Integer, nullable=True),
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
sa.Column("request_headers", sa.JSON, nullable=True),
sa.Column("request_body", sa.JSON, nullable=True),
sa.Column("provider_request_headers", sa.JSON, nullable=True),
sa.Column("response_headers", sa.JSON, nullable=True),
sa.Column("response_body", sa.JSON, nullable=True),
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
sa.Column("request_metadata", sa.JSON, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
# usage 表复合索引(优化常见查询)
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
# ==================== user_quotas ====================
op.create_table(
"user_quotas",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("quota_type", sa.String(50), nullable=False),
sa.Column("quota_usd", sa.Float, nullable=False),
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_usd", sa.Float, server_default="0.0"),
sa.Column("is_active", sa.Boolean, server_default="true"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== system_configs ====================
op.create_table(
"system_configs",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("key", sa.String(100), unique=True, nullable=False),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== user_preferences ====================
op.create_table(
"user_preferences",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
),
sa.Column("avatar_url", sa.String(500), nullable=True),
sa.Column("bio", sa.Text, nullable=True),
sa.Column(
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
),
sa.Column("theme", sa.String(20), server_default="light"),
sa.Column("language", sa.String(10), server_default="zh-CN"),
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
sa.Column("email_notifications", sa.Boolean, server_default="true"),
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== announcements ====================
op.create_table(
"announcements",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column("type", sa.String(20), server_default="info"),
sa.Column("priority", sa.Integer, server_default="0"),
sa.Column(
"author_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
sa.Column("is_pinned", sa.Boolean, server_default="false"),
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== announcement_reads ====================
op.create_table(
"announcement_reads",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
),
sa.Column(
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
)
# ==================== audit_logs ====================
op.create_table(
"audit_logs",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("event_type", sa.String(50), nullable=False, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column("api_key_id", sa.String(36), nullable=True),
sa.Column("description", sa.Text, nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("request_id", sa.String(100), nullable=True, index=True),
sa.Column("event_metadata", sa.JSON, nullable=True),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
# ==================== request_candidates ====================
op.create_table(
"request_candidates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("request_id", sa.String(100), nullable=False, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("candidate_index", sa.Integer, nullable=False),
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"key_id",
sa.String(36),
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("skip_reason", sa.Text, nullable=True),
sa.Column("is_cached", sa.Boolean, server_default="false"),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_type", sa.String(50), nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("latency_ms", sa.Integer, nullable=True),
sa.Column("concurrent_requests", sa.Integer, nullable=True),
sa.Column("extra_data", sa.JSON, nullable=True),
sa.Column("required_capabilities", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint(
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
),
)
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
# ==================== stats_daily ====================
op.create_table(
"stats_daily",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== stats_summary ====================
op.create_table(
"stats_summary",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column(
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
),
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== stats_user_daily ====================
op.create_table(
"stats_user_daily",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
)
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
# ==================== api_key_provider_mappings ====================
op.create_table(
"api_key_provider_mappings",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
)
op.create_index(
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
)
# ==================== provider_usage_tracking ====================
op.create_table(
"provider_usage_tracking",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer, server_default="0"),
sa.Column("successful_requests", sa.Integer, server_default="0"),
sa.Column("failed_requests", sa.Integer, server_default="0"),
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index(
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
)
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
def downgrade() -> None:
# Drop tables in reverse order (respecting foreign key dependencies)
op.drop_table("provider_usage_tracking")
op.drop_table("api_key_provider_mappings")
op.drop_table("stats_user_daily")
op.drop_table("stats_summary")
op.drop_table("stats_daily")
op.drop_table("request_candidates")
op.drop_table("audit_logs")
op.drop_table("announcement_reads")
op.drop_table("announcements")
op.drop_table("user_preferences")
op.drop_table("system_configs")
op.drop_table("user_quotas")
op.drop_table("usage")
op.drop_table("provider_api_keys")
op.drop_table("model_mappings")
op.drop_table("models")
op.drop_table("provider_endpoints")
op.drop_table("api_keys")
op.drop_table("global_models")
op.drop_table("providers")
op.drop_table("users")
# Drop ENUM types
op.execute("DROP TYPE IF EXISTS providerbillingtype")
op.execute("DROP TYPE IF EXISTS userrole")
@@ -1,315 +0,0 @@
"""remove_model_mappings_add_aliases
合并迁移:
1. 添加 provider_model_aliases 字段到 models 表
2. 迁移 model_mappings 数据到 provider_model_aliases
3. 删除 model_mappings 表
4. 添加索引优化别名解析性能
Revision ID: e9b3d63f0cbf
Revises: 20251210_baseline
Create Date: 2025-12-14 13:00:22.828183+00:00
"""
import json
from datetime import datetime, timezone
import sqlalchemy as sa
from alembic import op
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'e9b3d63f0cbf'
down_revision = '20251210_baseline'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def table_exists(bind, table_name: str) -> bool:
"""检查表是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar()
def index_exists(bind, index_name: str) -> bool:
"""检查索引是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE indexname = :index_name
)
"""
),
{"index_name": index_name},
)
return result.scalar()
def upgrade() -> None:
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
bind = op.get_bind()
# 1. 添加 provider_model_aliases 字段(如果不存在)
if not column_exists(bind, "models", "provider_model_aliases"):
op.add_column(
'models',
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
)
# 2. 迁移 model_mappings 数据(如果表存在)
session = Session(bind=bind)
model_mappings_table = sa.table(
"model_mappings",
sa.column("source_model", sa.String),
sa.column("target_global_model_id", sa.String),
sa.column("provider_id", sa.String),
sa.column("mapping_type", sa.String),
sa.column("is_active", sa.Boolean),
)
models_table = sa.table(
"models",
sa.column("id", sa.String),
sa.column("provider_id", sa.String),
sa.column("global_model_id", sa.String),
sa.column("provider_model_aliases", sa.JSON),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def normalize_alias_list(value) -> list[dict]:
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
if value is None:
return []
if isinstance(value, str):
try:
value = json.loads(value) if value else []
except Exception:
return []
if not isinstance(value, list):
return []
normalized: list[dict] = []
for item in value:
if not isinstance(item, dict):
continue
raw_name = item.get("name")
if not isinstance(raw_name, str):
continue
name = raw_name.strip()
if not name:
continue
raw_priority = item.get("priority", 1)
try:
priority = int(raw_priority)
except Exception:
priority = 1
if priority < 1:
priority = 1
normalized.append({"name": name, "priority": priority})
return normalized
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
# 仅当 model_mappings 表存在时执行迁移
if table_exists(bind, "model_mappings"):
mappings = session.execute(
sa.select(
model_mappings_table.c.source_model,
model_mappings_table.c.target_global_model_id,
model_mappings_table.c.provider_id,
)
.where(
model_mappings_table.c.is_active.is_(True),
model_mappings_table.c.provider_id.isnot(None),
model_mappings_table.c.mapping_type == "alias",
)
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
).all()
# 按 (provider_id, target_global_model_id) 分组,收集别名
alias_groups: dict = {}
for source_model, target_global_model_id, provider_id in mappings:
if not isinstance(source_model, str):
continue
source_model = source_model.strip()
if not source_model:
continue
if not isinstance(provider_id, str) or not provider_id:
continue
if not isinstance(target_global_model_id, str) or not target_global_model_id:
continue
key = (provider_id, target_global_model_id)
if key not in alias_groups:
alias_groups[key] = []
priority = len(alias_groups[key]) + 1
alias_groups[key].append({"name": source_model, "priority": priority})
# 更新对应的 models 记录
for (provider_id, global_model_id), aliases in alias_groups.items():
model_row = session.execute(
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
.where(
models_table.c.provider_id == provider_id,
models_table.c.global_model_id == global_model_id,
)
.limit(1)
).first()
if model_row:
model_id = model_row[0]
existing_aliases = normalize_alias_list(model_row[1])
existing_names = {a["name"] for a in existing_aliases}
merged_aliases = list(existing_aliases)
for alias in aliases:
name = alias.get("name")
if not isinstance(name, str):
continue
name = name.strip()
if not name or name in existing_names:
continue
merged_aliases.append(
{
"name": name,
"priority": len(merged_aliases) + 1,
}
)
existing_names.add(name)
session.execute(
models_table.update()
.where(models_table.c.id == model_id)
.values(
provider_model_aliases=merged_aliases if merged_aliases else None,
updated_at=datetime.now(timezone.utc),
)
)
session.commit()
# 3. 删除 model_mappings 表
op.drop_table('model_mappings')
# 4. 添加索引优化别名解析性能
# provider_model_name 索引(支持精确匹配,如果不存在)
if not index_exists(bind, "idx_model_provider_model_name"):
op.create_index(
"idx_model_provider_model_name",
"models",
["provider_model_name"],
unique=False,
postgresql_where=sa.text("is_active = true"),
)
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
if bind.dialect.name == "postgresql":
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'models'
AND column_name = 'provider_model_aliases'
AND data_type = 'json'
) THEN
ALTER TABLE models
ALTER COLUMN provider_model_aliases TYPE jsonb
USING provider_model_aliases::jsonb;
END IF;
END $$;
"""
)
# 创建 GIN 索引
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
ON models USING gin(provider_model_aliases jsonb_path_ops)
WHERE is_active = true
"""
)
def downgrade() -> None:
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
bind = op.get_bind()
# 1. 删除索引
op.drop_index("idx_model_provider_model_name", table_name="models")
if bind.dialect.name == "postgresql":
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
# 将 jsonb 列还原为 json
op.execute(
"""
ALTER TABLE models
ALTER COLUMN provider_model_aliases TYPE json
USING provider_model_aliases::json
"""
)
# 2. 恢复 model_mappings 表
op.create_table(
'model_mappings',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('source_model', sa.String(200), nullable=False),
sa.Column(
'target_global_model_id',
sa.String(36),
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
)
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
# 3. 移除 provider_model_aliases 字段
op.drop_column('models', 'provider_model_aliases')
@@ -1,47 +0,0 @@
"""add first_byte_time_ms to usage table
Revision ID: 180e63a9c83a
Revises: e9b3d63f0cbf
Create Date: 2025-12-15 17:07:44.631032+00:00
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '180e63a9c83a'
down_revision = 'e9b3d63f0cbf'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None:
"""应用迁移:升级到新版本"""
bind = op.get_bind()
# 添加首字时间字段到 usage 表(如果不存在)
if not column_exists(bind, "usage", "first_byte_time_ms"):
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
# 删除首字时间字段
op.drop_column('usage', 'first_byte_time_ms')
@@ -1,110 +0,0 @@
"""refactor global_model to use config json field
Revision ID: 1cc6942cf06f
Revises: 180e63a9c83a
Create Date: 2025-12-16 03:11:32.480976+00:00
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1cc6942cf06f'
down_revision = '180e63a9c83a'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None:
"""应用迁移:升级到新版本
1. 添加 config 列
2. 把旧数据迁移到 config
3. 删除旧列
"""
bind = op.get_bind()
# 检查是否已经迁移过(config 列存在且旧列不存在)
has_config = column_exists(bind, "global_models", "config")
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
if has_config and not has_old_columns:
# 已完成迁移,跳过
return
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
if not has_config:
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
if has_old_columns:
op.execute("""
UPDATE global_models
SET config = jsonb_strip_nulls(jsonb_build_object(
'streaming', COALESCE(default_supports_streaming, true),
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
'description', description,
'icon_url', icon_url,
'official_url', official_url
))
""")
# 3. 删除旧列
op.drop_column('global_models', 'default_supports_streaming')
op.drop_column('global_models', 'default_supports_vision')
op.drop_column('global_models', 'default_supports_function_calling')
op.drop_column('global_models', 'default_supports_extended_thinking')
op.drop_column('global_models', 'default_supports_image_generation')
op.drop_column('global_models', 'description')
op.drop_column('global_models', 'icon_url')
op.drop_column('global_models', 'official_url')
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
# 1. 添加旧列
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
# 2. 从 config 恢复数据
op.execute("""
UPDATE global_models
SET
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
description = config->>'description',
icon_url = config->>'icon_url',
official_url = config->>'official_url'
""")
# 3. 删除 config 列
op.drop_column('global_models', 'config')
@@ -1,57 +0,0 @@
"""add proxy field to provider_endpoints
Revision ID: f30f9936f6a2
Revises: 1cc6942cf06f
Create Date: 2025-12-18 06:31:58.451112+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'f30f9936f6a2'
down_revision = '1cc6942cf06f'
branch_labels = None
depends_on = None
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def get_column_type(table_name: str, column_name: str) -> str:
"""获取列的类型"""
bind = op.get_bind()
inspector = inspect(bind)
for col in inspector.get_columns(table_name):
if col['name'] == column_name:
return str(col['type']).upper()
return ''
def upgrade() -> None:
"""添加 proxy 字段到 provider_endpoints 表"""
if not column_exists('provider_endpoints', 'proxy'):
# 字段不存在,直接添加 JSONB 类型
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
else:
# 字段已存在,检查是否需要转换类型
col_type = get_column_type('provider_endpoints', 'proxy')
if 'JSONB' not in col_type:
# 如果是 JSON 类型,转换为 JSONB
op.execute(
'ALTER TABLE provider_endpoints '
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
)
def downgrade() -> None:
"""移除 proxy 字段"""
if column_exists('provider_endpoints', 'proxy'):
op.drop_column('provider_endpoints', 'proxy')
@@ -1,86 +0,0 @@
"""add stats_daily_model table and rename provider_model_aliases
Revision ID: a1b2c3d4e5f6
Revises: f30f9936f6a2
Create Date: 2025-12-20 12:00:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = 'f30f9936f6a2'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
# 1. 创建 stats_daily_model 表
if not table_exists('stats_daily_model'):
op.create_table(
'stats_daily_model',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
sa.Column('model', sa.String(100), nullable=False),
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), onupdate=sa.func.now()),
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
)
# 创建索引
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
return index_name in indexes
def downgrade() -> None:
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
# 恢复列名
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
# 删除表
if table_exists('stats_daily_model'):
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
op.drop_table('stats_daily_model')
@@ -1,65 +0,0 @@
"""add usage table composite indexes for query optimization
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-12-20 15:00:00.000000+00:00
"""
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'b2c3d4e5f6g7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""为 usage 表添加复合索引以优化常见查询
注意:这些索引已经在 baseline 迁移中创建。
此迁移仅用于从旧版本升级的场景,新安装会跳过。
"""
conn = op.get_bind()
# 检查 usage 表是否存在
result = conn.execute(text(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
))
if not result.scalar():
# 表不存在,跳过
return
# 定义需要创建的索引
indexes = [
("idx_usage_user_created", "ON usage (user_id, created_at)"),
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
]
# 分别检查并创建每个索引
for index_name, index_def in indexes:
result = conn.execute(text(
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
))
if result.scalar():
continue # 索引已存在,跳过
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
def downgrade() -> None:
"""删除复合索引"""
conn = op.get_bind()
# 使用 IF EXISTS 避免索引不存在时报错
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
))
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_apikey_created"
))
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_user_created"
))
@@ -1,161 +0,0 @@
"""add ldap authentication support
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2026-01-01 14:00:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'c3d4e5f6g7h8'
down_revision = 'b2c3d4e5f6g7'
branch_labels = None
depends_on = None
def _type_exists(conn, type_name: str) -> bool:
"""检查 PostgreSQL 类型是否存在"""
result = conn.execute(
text("SELECT 1 FROM pg_type WHERE typname = :name"),
{"name": type_name}
)
return result.scalar() is not None
def _column_exists(conn, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = conn.execute(
text("""
SELECT 1 FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""),
{"table": table_name, "column": column_name}
)
return result.scalar() is not None
def _index_exists(conn, index_name: str) -> bool:
"""检查索引是否存在"""
result = conn.execute(
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": index_name}
)
return result.scalar() is not None
def _table_exists(conn, table_name: str) -> bool:
"""检查表是否存在"""
result = conn.execute(
text("""
SELECT 1 FROM information_schema.tables
WHERE table_name = :name AND table_schema = 'public'
"""),
{"name": table_name}
)
return result.scalar() is not None
def upgrade() -> None:
"""添加 LDAP 认证支持
1. 创建 authsource 枚举类型
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
3. 创建 ldap_configs 表
"""
conn = op.get_bind()
# 1. 创建 authsource 枚举类型(幂等)
if not _type_exists(conn, 'authsource'):
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
# 2. 在 users 表添加字段(幂等)
if not _column_exists(conn, 'users', 'auth_source'):
op.add_column('users', sa.Column(
'auth_source',
sa.Enum('local', 'ldap', name='authsource', create_type=False),
nullable=False,
server_default='local'
))
if not _column_exists(conn, 'users', 'ldap_dn'):
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
if not _column_exists(conn, 'users', 'ldap_username'):
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
# 创建索引(幂等)
if not _index_exists(conn, 'ix_users_ldap_dn'):
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
if not _index_exists(conn, 'ix_users_ldap_username'):
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
# 3. 创建 ldap_configs 表(幂等)
if not _table_exists(conn, 'ldap_configs'):
op.create_table(
'ldap_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('server_url', sa.String(length=255), nullable=False),
sa.Column('bind_dn', sa.String(length=255), nullable=False),
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
sa.Column('base_dn', sa.String(length=255), nullable=False),
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
"""回滚 LDAP 认证支持
警告:回滚前请确保:
1. 已备份数据库
2. 没有 LDAP 用户需要保留
"""
conn = op.get_bind()
# 检查是否存在 LDAP 用户,防止数据丢失
if _column_exists(conn, 'users', 'auth_source'):
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
ldap_user_count = result.scalar()
if ldap_user_count and ldap_user_count > 0:
raise RuntimeError(
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
)
# 1. 删除 ldap_configs 表(幂等)
if _table_exists(conn, 'ldap_configs'):
op.drop_table('ldap_configs')
# 2. 删除 users 表的 LDAP 相关字段(幂等)
if _index_exists(conn, 'ix_users_ldap_username'):
op.drop_index('ix_users_ldap_username', table_name='users')
if _index_exists(conn, 'ix_users_ldap_dn'):
op.drop_index('ix_users_ldap_dn', table_name='users')
if _column_exists(conn, 'users', 'ldap_username'):
op.drop_column('users', 'ldap_username')
if _column_exists(conn, 'users', 'ldap_dn'):
op.drop_column('users', 'ldap_dn')
if _column_exists(conn, 'users', 'auth_source'):
op.drop_column('users', 'auth_source')
# 3. 删除 authsource 枚举类型(幂等)
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
if _type_exists(conn, 'authsource'):
conn.execute(text("DROP TYPE authsource"))
@@ -1,131 +0,0 @@
"""add_management_tokens_table
Revision ID: ad55f1d008b7
Revises: c3d4e5f6g7h8
Create Date: 2026-01-06 15:24:10.660394+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'ad55f1d008b7'
down_revision = 'c3d4e5f6g7h8'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
indexes = inspector.get_indexes(table_name)
return any(idx["name"] == index_name for idx in indexes)
except Exception:
return False
def constraint_exists(table_name: str, constraint_name: str) -> bool:
"""检查约束是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
constraints = inspector.get_unique_constraints(table_name)
if any(c["name"] == constraint_name for c in constraints):
return True
# 也检查 check 约束
check_constraints = inspector.get_check_constraints(table_name)
if any(c["name"] == constraint_name for c in check_constraints):
return True
return False
except Exception:
return False
def upgrade() -> None:
"""应用迁移:创建 management_tokens 表"""
# 幂等性检查
if table_exists("management_tokens"):
# 表已存在,检查是否需要添加约束
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
return
op.create_table('management_tokens',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('token_prefix', sa.String(length=12), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('allowed_ips', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
# 添加用户名称唯一约束
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
def downgrade() -> None:
"""回滚迁移:删除 management_tokens 表"""
# 幂等性检查
if not table_exists("management_tokens"):
return
# 删除约束
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
# 删除索引
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_user_id"):
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_is_active"):
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
# 删除表
op.drop_table('management_tokens')

Some files were not shown because too many files have changed in this diff Show More