Repository navigation
v0.18.0
[v0.18.0] — 2026-09-03
修复
- New API 中继链从「看起来绑上」变成持久且可证明(#1179,经 #1187 闭合):New API v1 登录返回的是短命 dashboard JWT,Metapi 却把它当长期凭据持久化,后续模型刷新因此塌成空列表;现代 New API 的令牌列表把 relay key 显示成掩码值,Metapi 又把掩码显示值当可用路由凭据落成
masked_pending;模型列表此前要等后台调度先跑过一遍才出现,登录时并不可得。现在登录时把 fresh v1 JWT 提升为 New API 的长期 dashboard PAT(发不出持久凭据就拒绝绑定),并用 live JWT 撤销临时上游登录会话——即使代理剥掉 refresh cookie 也照撤;steady state 用不过期的 PAT,不再重复登录,只有显式 login/recovery 才轮换 New API 那唯一一个 dashboard PAT。掩码 key 用一次所有权校验过的/api/token/batch/keys批量换回真实 key,掩码显示值绝不作为路由凭据返回,水合失败沿单 token 与 verify-token 路径向上抛而不是装作没有 token。端到端门禁同时收紧:EXPECT_RELAY=1成为默认,账号模型为空、/v1/models为空、结构化错误、非 2xx 完成、缺失或空内容、错误完成标记全部判失败;显式无通道链用EXPECT_RELAY=0报 SKIP 而不是 PASS;CI 把 New API 接到确定性的本地 OpenAI mock 上核对真实完成标记与上游收到的具体模型,且不再绕过被测 adapter 注入完整 relay key;复用下游 key 时重新断言其通配策略(空模型列表就是刻意 deny-all)。升级后需要重新登录一次;令牌不需要手工绑定。 - 没有可用通道的 503 现在说清为什么(#1179,经 #1186 闭合):通道选择把「无可选」报成
(nil, nil),失败路径因此记err=<nil>并回503 {"error":{"message":"No available channels"}}——「没有启用的路由匹配这个模型」「路由匹配但每个通道令牌未绑定/停用」「所有通道都在冷却,或下游 key 策略排除了站点」三种完全不同的运维问题输出一字不差。现在proxy.ExplainNoChannel(...)渲染一行紧凑结论:解释器的 verdict,加上路由匹配但无人合格时的主候选拒绝原因(没有可用通道(…):令牌不可用);它经可选SelectionExplainer接口触达路由器(与AvailableModelsSource同款 idiom),不能解释的单测桩降级回旧文案,解释出错只给出「无原因」而不是二次失败。原因同时进 503 body(稳定前缀No available channels保留)、WARN 日志reason=与运营面向的 all-failed 事件。 - 重建路由不再随发出它的 HTTP 请求一起死,清缓存也不再删掉重建要用的输入(#1174,经 #1185 闭合):
POST /api/routes/rebuild曾把整趟 pass 跑在r.Context()上,refreshModels: true(UI 就是发这个)时每个活跃账号一次上游往返、单次上限 30s,真实车队要几分钟;web client 只给 30s 超时,于是客户端先挂断,日志里就是context canceled,重建永久失败。现在 handler 用context.WithoutCancel(r.Context())分离请求上下文并配自己的 30 分钟预算,浏览器或反向代理放弃不再取消它要求的路由状态;SyncAllAccountModels的收尾重建跑在局部有界上下文上,被打断的 pass 仍用已拿到的可用性重组路由(这一步不碰上游);收尾重建总是执行(无变化时短路不写),不再被success > 0卡住。web client 的重建超时从共享 30s 改为 5 分钟,且不再绑定服务器忽略的wait标志。第二个缺陷:POST /api/settings/maintenance/clear-cache(UI:清除缓存并重建路由)曾删掉token_routes、model_availability和route_channels,再排一个从这两张表重组route_channels的重建——要重建的东西已经被它删光:每条路由定义消失,每个已发现模型都要重新上游拉取(每账号 30s,凭据过期就直接失败),手工通道挂载也被抹掉。现在该端点只失效进程内缓存并排同一个真实后台重建,不删任何行;清业务行是factory-reset的职责,那个端点仍然会做。 - 账号编辑保存的凭据模式不再蒸发,换不过去的模式被 400 拒绝(#1176,经 #1184 闭合):编辑对话框每次保存都发
credentialMode,但AccountUpdatePayload没有这个字段,handler 解码时把它丢掉——建账号时选 API key、编辑改成 session、拿到成功 toast,重开对话框又变回 API key。现在credentialMode进入更新 payload 并合并进extraConfig(创建路径写的同一个存储,也是ResolveStoredCredentialMode第一个读的地方);请求的模式在碰任何凭据字段之前解析,因为shouldMirrorAPIKeyToken看的是已存模式——此前显式切到 session 也会把 session 凭据抄进api_token,而 session 凭据不是 API key,代理的凭据回落会把它当 API key 发出去。存储凭据撑不起的模式现在 400 而不是落库:没有accessToken的session、没有apiToken的apikey,此前都会产出「行上声称可认证、实际永远不能认证」的账号。 - AnyRouter 校验失败终于告诉运营它要什么凭据(#1133,经 #1195 闭合):运营配好 AnyRouter 站点、粘贴一段对上证明可用的 cookie,得到的只有
token verification failed,平台到底要什么没有任何提示。adapter 并不缺——AnyRouterAdapter内嵌NewApiAdapter,cookie/session 校验本来就会尝试,但它刻意关掉 token 管理(GetAPIToken返回nil, nil),因为 AnyRouter 的 API-key 流程不是 New API 的/api/token/契约;校验因此返回nil/空/unknown时,bind 路径与 verify 路径都把信号扔掉、对所有平台回同一句泛泛的话。现在两处共用一个credentialVerificationFailureMessage(platform):AnyRouter 明确说 access-token/API-key 绑定不支持、必须用 session 模式、字段接受session=<value>或完整Cookie:头、Platform User ID 仅在上游强制New-Api-User/User-id时才需要、绑定前必须用tokenType=session通过校验;其它平台保留原指引并补同一形状提示(session 平台 →session=<value>或完整 cookie 头,API-key 平台 → API Key 模式)。 - 「同步站点令牌」现在幂等,掩码 key 不再每点一次多一行(#1193,经 #1196 闭合):
SyncTokensFromUpstream此前只对 ready 行去重;上游在令牌列表里掩码 relay key(New API 系返回sk-abc***xyz)时,行解析成masked_pending,永远匹配不上,于是每次同步都走 INSERT、再插一份同一条 token 的副本——正是运营每点一次多一行的来源。现在按精确 key 值匹配已有行、优先 ready 行(已水合的 key 继续对账进同一可用行)、回落 masked-pending 行;UPDATE 保留匹配行解析出的value_status而不是强制写成ready——掩码行诚实地保持掩码,不声称持有没有的 key。已知残余(文档化、不在本版修):#1187 之前绑定的账号可能留一条masked_pending旧行,之后同步取回真实 key 时会再插一条可用行、旧的掩码行留在原地——一次性、可见标记、不可路由的重复项,运营可删;对账它需要平台信息,而该 service 函数没有,为一条一次性外观行不值得加这堆机器。 - 删除一张表不再让删除之前写的每个备份都恢复不了(#1201):删表曾让
validateBackupImportTableKeys拒绝它不认识的任何 payload key,于是升级过本版之后,一个本来有效的旧备份恢复直接变成400 {"error":"import failed: unknown table proxy_debug_traces"}(被现有的向后兼容测试抓到)。现在按来源区分而不是维护一张「退役表」清单:策略排除表(admin_sessions、admin_audit_logs…)无论来源都响亮 400;手写/粘贴 JSON 里的未知 key 仍 400——它最可能是拼写错误,其行会静默永远不落地;真正导出文件里的未知 key 说明是本 build 已删的表,跳过并在 import 与 preview 两个响应里报告为ignoredTables(WebDAV 恢复路径同样)。跳过是安全的:import/preview 循环遍历注册表再拿 payload key 查注册表,不认识的 key 从不被读。legacyBackupTables因此保留它的 28 个历史条目(含这四张已删表):它记录的是旧 build 写过什么、而不是本 schema——这正是它能当升级门的原因。
移除
-
未发布的 Electron 桌面外壳连同公开的
/api/desktop/health一起删除(#1197):electron/(main.js 600 行、preload.js、README、package.json、托盘图标)、scripts/build-electron.sh/.ps1、Makefile 的electron-buildtarget、web/scripts/desktop/generate-icons.mjs、两张 desktop 图标、npm 的desktop:icons,以及GET /api/desktop/health、它在isPublicAPIRoute白名单里的条目和对两个图标的根文件服务全部移除。删除证据:发布流水线从未产出过桌面产物——release job 只从./cmd/server与./cmd/migrate构建metapi-<os>-<arch>和metapi-migrate-<os>-<arch>,workflow 里唯一的 "desktop" 字符串是 visual-regression 的 viewport 注释;外壳只能由维护者手工make electron-build跑到,且web/src里没有对应ipcRenderer/contextBridge/window.electron消费者,它只是把一个 SPA 装进窗口。删除顺带拿掉一个公开、未认证、为托盘而设的端点;scripts/regen-ts-fixture.sh的就绪探针从/api/desktop/health改探/ready。 -
update-center 的五个操作臂与调度器桩删除,状态卡保留(#1199):
POST /api/update-center/check、PUT /config、POST /deploy、POST /rollback、GET /tasks/{id}/stream加scheduler/update_center.go与METAPI_ENABLE_UPDATE_CENTER全部移除;这个调度器本来就是 log-only no-op(环境变量自己的文档也这么说)。GET /api/update-center/status与它渲染的 UI 版本卡保留——UpdateCenterSection仍用api.getUpdateCenterStatus()。 -
model-tester 的流式/任务臂删除,同步探针保留(#1199):
POST /api/test/proxy(连同/stream、/jobs、GET/DELETE /jobs/{jobId})与POST /api/test/chat/stream、POST /api/test/chat/jobs、GET/DELETE /api/test/chat/jobs/{jobId}删除;这些 stream/job 臂从未实现成 SSE,model-tester/api.ts在 master 的注释里自己承认。POST /api/test/chat(同步探针实际调用的那个)保留。证据:每个被删路由在web/src/lib/api/都有客户端包装,master 上这些包装在lib/api/之外零引用——没有组件、hook 或测试调过它们。 -
proxy debug-trace 子系统整个删除:两张表、五个索引、两个读路由、九个
PROXY_DEBUG_*开关、设置 UI 与 web client(#1201):九个开关会被解析、持久化、热生效并渲染进设置 UI,两个路由也会把表读回来——但从没有任何东西写过一行:master 全树里仅有的INSERT INTO proxy_debug_*在e2e/e2e_backup_test.go与handler/admin/stats_detail_test.go测试种子里。读路由因此只能返回空列表,动任何开关都不改变行为;一个永久为空、却带九个用户可见控件的表面比没有更糟。 -
proxy_files、PROXY_FILE_RETENTION_*与scheduler/file_retention.go删除(#1201):master 上每个生产引用都是管线而非使用——auth/context.go里的注释、修剪它的 retention 调度器、备份包含、DDL 与注册表;没有 writer,没有 reader。这张表被创建、备份、修剪,却从未被写入。 -
admin_snapshots与admin-snapshot调度器删除(#1201):warmTarget是显式 stub(自己的注释写着「Stub: regenerate snapshot…」);调度器里唯一碰这张表的语句是DELETE FROM admin_snapshots WHERE expires_at < ?——周期性清理没有任何代码插入过的行。而每趟 pass 仍然扇出 4 个 goroutine 加一个wg.Wait只为了打 debug 日志。该调度器有用的那一半(usage aggregator 的RunProjectionPass)没有丢:usage-aggregation 调度器仍在自己的 interval 上跑它。 -
RESPONSES_COMPACT_FALLBACK_TO_RESPONSES_ENABLED与ShouldFallbackCompactResponsesToResponses删除(#1201):git grep ShouldFallbackCompactResponsesToResponses master -- '*.go' | grep -v _test.go只剩定义本身;它的两个测试(一个 table test 与一份固定期望值矩阵)随之一并删除——只练不可达函数的测试不把守任何东西。 -
RouteRefreshWorkflow家族删除(#1187):生产代码没有它的实现者,连其配置字段与 test-only mock 一起移除;路由器去掉这个没人实现的 refresh 接口后照常构建与选择。 -
六个无调用者的 helper 与四个不可能失败的测试删除(#1198、#1190):
stripModelPrefix、normalizeURLForDetection、normalizeURLProtocol(platform 内git grep只有定义 + 各自测试)、setReadinessDrainingForTest(一行转发markReadinessDraining的 pass-through,测试改直接调后者)、cronRunner.removeJob、toISOTime全部移除。同时删掉四个 vacuous 测试:TestOneApiAdapter_BalanceQuotaMinusUsed(零断言)、TestOneApiAdapter_BalanceParseLogic(只断言本地字面量算术、不调产品代码)、TestOpenAiAdapter_GetModels(接受任何结果)、TestListAdapters_Copy(唯一的if是空 body,注释承认「只保证不 panic」)——只练不可达函数或不可能失败的测试只会制造维护重量、报告假信心。scheduler 里的TestClampInt也随之删除(重测config.ClampInt,config 自己已把守),TestFormatTimeToSQL/TestCountResults折进表格。 -
14 份维护者过程史从公开仓移出(#1191):
docs/internal/STATE.md(点状产品状态)、docs/internal/log.md(按日收口日志)、docs/internal/progress/MASTER.md(会话任务台账)、docs/internal/benchmark.md(一次性测量)与docs/internal/analysis/下 9 份(竞品研究、凭据 IA、db pool 预算、P0-585 验证、包边界、Redis 共享状态、TS→Go gap、UI/UX 审计、wave12 需求真相)删除;它们的角色改由 GitHub issues/releases(现状与开放项)、CHANGELOG.md(版本叙事)、docs/architecture.md§"Ownership and boundary map"(包归属 + 例外边,已由docs/package_boundary_test.go机器强制)与scripts/verify-cascade-prod.sh+e2e/e2e_p0585_production_test.go(运营门级 cascade 证据流程)承接。 -
再清掉两份已无读者的维护者笔记(#1194):
docs/internal/analysis/e2e-acceptance-platform.md(运营门级 live-credential 验收制度 + wave 历史)与docs/internal/design/events-structured.md(冻结工作项的「实施中」设计笔记)删除;引用它们的两处注释改为就地陈述契约,web/knip.config.ts里引用已删web/scripts/oneoff/**的 ignore 条目一并移除。 -
docs/api.md从 456 行删到 28 行(#1187):原来是重复 deep-link 桩的堆积,现在只剩领域索引;各领域页仍是详细出处。 -
无破坏性迁移(#1201):已升级的安装保留这四张孤儿表;没有代码读它们,也没有代码 drop 它们。
cmd/migrate只拷贝注册表里的表,所以方言迁移不会搬走它们(永远为空的)行。已写进docs/deployment.md。 -
routing里整条死的 pricing override 链与一个fmt.Errorf别名删除(#1204):NormalizePricingRatio、EstimateProxyCostFromModel、BuildPricingOverrideModel全仓零调用者——非测试引用只剩它们自己的定义与文档注释。只经这三个函数可达的私有符号一并删除:asFiniteNumber(唯一调用者是NormalizePricingRatio)、jsonNumber接口(唯一用例是asFiniteNumber里一个 type-switch 分支)、ProxyBillingPricingOverride(唯一用例是BuildPricingOverrideModel的参数)。jsonNumber的注释自称是为了「避免在asFiniteNumber的每个调用点导入 encoding/json」——导入只落在一个文件里、不落在调用点,所以它什么也没买到;何况全仓只有一处UseNumber()(handler/admin/site_announcements.go),json.Number本来就永远到不了那个 type switch。三个只练这些函数的测试随之删除,其中TestEstimateProxyCostFromModel_MatchesCalculateModelUsageCost断言的是「别名返回被别名者返回的东西」,它自己的注释写着 "thin alias over CalculateModelUsageCost"。scheduler的formatErr是fmt.Errorf的一行转发(func formatErr(f string, args ...any) error { return fmt.Errorf(f, args...) }),六个调用点改为直接调用fmt.Errorf,它自己的测试一并删除;helpers.go保留stringsTrimLower(真实逻辑:trim + 小写 + 空值默认"active",两个生产调用者)。存活的 pricing API(FallbackTokenCost、CalculateModelUsage*、ResolveCacheRatio、DefaultCacheRatioForModel、SetCacheRatioDefaults、CacheAwarePerMillionRates)一字未动且仍有覆盖。
开发者可见
- schema 注册表 37 → 33 张表,
allStructs35 → 31(#1201):删表后注册表随之收缩;两个自动迁移门禁(TestPostgresAutoMigrateAllTables/TestSQLiteAutoMigrateAllTables)改为遍历AllTableNames(),不再手抄第三份清单——「这个方言创建注册表声明的表」是它们的工作,而注册表出现之前手抄清单正是漂移来源。绝对库存只在TestTableCount里以字面量 31 钉住一次,并交叉核对 struct 清单与tableColumnCount;加表/删表因此必须是刻意动作。两个门禁都在真实 PostgreSQL 上验过(PG_TEST_DSN,跑而非跳过)。 handler/admin/accounts.go1726 行按关注点拆成 6 个文件(#1192):路由注册、handler struct、两张快照缓存留在accounts.go;login/verify/rebind 进accounts_auth.go,创建进accounts_create.go,列表/分页/过滤/快照进accounts_list.go,update/delete/batch 进accounts_write.go,health-refresh 结果类型与refreshBalance进accounts_health_refresh.go。纯移动:六个文件声明的 func 集合与 master 的accounts.go28 对 28 完全相同,归一化 body diff 为空,签名/接收者/可见性/JSON 形状/路由/调用点零改动。- 四个 web zod schema 套件折成表格(#1188):
accounts-schema、sites-schema、tester-schema、checkin-schema四个套件把「每个输入一个断言」的形状声明一次、输入写成it.each行,共 −227 行、零新抽象、零产品代码改动;前端用例数 46→47(accounts 的空白凭据 session/api-key 两模式拆成两行,一个模式的回归不再躲在另一个后面)、30→30、24→24、21→21,断言一个没丢。 - 账号 handler 的 14 个同形状状态码测试折成两张表(#1189):全部 body 只有「造一个请求、断言一个状态码」的 14 个函数变成
TestAccounts_BadRequestValidation(7 行,全部POST→400)与TestAccounts_ErrorResponseCodes(7 行,POST/GET/PUT各自期望码),14 → 14 场景一一对应;只动了 3290 行套件里的 −69 行,无产品代码、无新 helper。 - 七个 status-badge-variant 套件折成一个 29 行布线门禁(#1200):七个按 feature 复制的
status-badge-variants.test.tsx(637 行)并成一个表驱动布线门禁 + dashboard 自己的小文件,8 文件 +281/−538。这是「feature X 的列单元格对状态 Z 渲染语义变体 Y」的布线断言,不是 Badge 原语 recipe 断言(原语契约留在原文件不动)。29 个列/badge 用例进一个it.each表,dashboard 的 3 个留在原文件(其形状确实不同:mock@/lib/api、整段渲染、数组 badge);前端用例 32 → 32。两个独立变异探针(accounts 的healthy.variant、routes 的 channel-summary 阶梯)都红,证明门禁真的把守。 - proxy 重试/失败判定 104 个
t.Run折成表格(#1202):retry_policy_test.go473→223 行、67→6 个t.Run块、决策用例 71→71(>= 500 always retryable原来一个 subtest 内循环 5 个码,现在是 5 行离散行,失败时各自具名);failure_judge_test.go338→246 行、37→16 个块、23 折叠→23、14 个不同形状的用例不动。零断言丢失是机械核对的,不是肉眼:每个ShouldRetryProxyRequest(...)/ShouldAbortSameSiteEndpointFallback(...)的输入→期望 tuple 从原文件抽取后与新表行按集合比对,71=71 且两个差集为空;TestDetectHasUpstreamOutput14=14、TestHasCompletionContentFromPayload9=9。 - 15 份 per-adapter
PlatformName测试并成一张 16 行注册表门禁(#1203):每个platform/<x>_test.go各自一个单断言测试,现在TestAdapterPlatformNames一张表全装下,并新覆盖sensetime(此前根本没有 per-file 测试,只在TestGetAdapter_SenseTime里间接断过)。正确性与完整性刻意分开:每行wantName是显式字面量(生产改名必红);行跑完后一个 anti-shrink 交叉检查断言表名集合与注册表ListAdapters()双向相等——加 adapter 不补行会红,删行也会红。两个半面都用变异探针验证过。 handler/admin的 18 个错误路径测试折成两张数据行表(#1206):sites_test.go与account_tokens_test.go里各 9 个「造一个库、发一个请求、断言一个状态码」的同形函数,变成TestSites_ErrorStatusCodes与TestTokens_ErrorStatusCodes:18 → 18 个子测试、名字保留(…/Create_EmptyName)。行是纯数据({name, seed, method, path, body, wantStatus, wantErr}),循环体只做一件事:按 seed 播种、把{id}换成播种出来的 id、按 method 派发到既有的doGet/doPostJSON/doPutJSON/doDelete、断言——每行不再各自带一个匿名函数,也没有新 helper 或新 harness,与 #1189 / #1202 / #1203 已合入的表格风格同形。消息片段照旧断言(Create_Duplicate仍要求"already exists")。Postgres 双胞胎刻意不动:它们走另一套方言 harness,把 SQLite 行混进去会让一次失败说不清是哪个方言。两个变异探针(sites 空名 400→404、tokens 非法 action 400→404)分别把两张表打红,生产文件按 sha256 逐字节还原。- CI 的
bun install对瞬时故障有界重试,Dockerfile 缓存挂载路径修对(#1181):两个缺陷都在绿色构建里看不见。①bun install对瞬时 registry 故障零重试:损坏/截断 tarball 报Integrity check failed,这不是被测提交的属性、重跑就装得上,却能挂掉必选检查、挡住合并;仅 2026-09-03 一天就命中四个不同包、跨三个 job(a11y 的recharts、visual-regression 的@base-ui/react与@oxlint/binding-linux-x64-gnu、docker-push 的lightningcss-linux-arm64-musl)。② Dockerfile 的 cache mount 从未缓存过任何东西:挂在~/.bun/install-cache,bun 读写的是~/.bun/install/cache——一个连字符 vs 一个斜杠,那句「keeps the Bun install cache across builds」注释是假的,每次镜像构建都从零重下全部 tarball。两个调用点现在共用scripts/bun-install.sh里一份策略(放在scripts/因为.dockerignore把.github排除在构建上下文外);重试刻意很窄,只对 tarball 完整性/解压失败、连接重置/超时、DNS 与 fetch 错误重试,--frozen-lockfile不匹配、manifest 缺失、404、生命周期脚本报错一律第一次就失败,重试前清安装缓存。scripts/bun_install_wiring_test.go把接线钉住:workflow 的run:不得裸调bun install、恰好 5 处 web-deps 安装必须走脚本、Dockerfile 必须调它且挂 bun 真实缓存路径、脚本必须保留有界尝试/清缓存/非瞬时不重试三个分支。 - 仓库卫生缺口补齐(#1194):
.gitignore补.env.*(保留!.env.example)、*.log与/dist-bin/;playwright 缓存 key 从哈希不存在的web/bun.lockb改为三处哈希web/bun.lock(此前hashFiles对不存在路径返回空串、缓存 key 恒定、依赖升级从不失效);Makefile 删除没有 recipe 的.PHONY(p0585-probe、p0585-e2e);docs/internal/design/a11y-checklist.md删掉一条已解决的 Wave-9 残留;docs/README.md补上内部地图缺的internal/web-package-boundaries.md与internal/design/state-stability.md。
已知遗留(本版未做)
#1132(请求头模板)是新产品面,继续排队:冻结规则是核心链被证明是绿的之前不加产品面;本版只做收紧与删除,不接它。itoa在handler/proxy保留:那份把它判死的审计是错的——它有约 40 个同包测试调用者(account_tokens_test.go、token_routes_test.go、tags_test.go、search_test.go、videos*_test.go)加上handler/proxy/helpers_test.go里自己的门禁(service/checkin/round_aggregation_test.go:92的本地itoa无关)。- 删掉
TestOpenAiAdapter_GetModels后,OpenAiAdapter.GetModels不再有直接单元门禁:它此前也并非真被把守——旧测试接受任何结果(_ = models; _ = err);真正门禁需要一个 httptest 上游,那是单独一件事。ListAdapters的情况不同、已不再无人把守:#1203 的 anti-shrink 交叉检查调用它并断言其名字集合与表双向相等,少返回一个 adapter 就红——被删的TestListAdapters_Copy原本只断言「不 panic」。 PayloadRules没有运行时消费者、OpenAiServiceTierRules没有读者,而docs/configuration.md仍把两者写成可用:从 v0.17.1 带过来,仍未决——改文档还是建引擎;本版未动。