尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
freehire 按用户 LLM 花费归因:`internal/ai/llmkey` 的密钥铸造、解析与回收机制
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载导读freehire 的每一次模型调用最终都会落到某个 LLM 网关的凭证上。要让谁花了多少、花在哪个功能上这个问题有答案就必须让每个已登录用户的调用走他自己那把虚拟密钥而不是全站共用的一把服务密钥。internal/ai/llmkey/AGENTS.md 正是这个子系统的工作手册它讲清了凭证如何铸造、如何解析、如何退役以及哪些设计取舍是刻意留下的陷阱。本文以该文档为主线结合 llmkey.go、resolver.go、bind.go 与 scope_test.go 的源码实现把这套机制拆成可操作、可验证的工程指南。读完你能掌握四个环境变量如何开关整个特性、Credential为什么必须是秘密 id两段、模板密钥里的 provider 策略如何被一次性复制、并发首次调用如何竞争、以及为什么归因失败永远不许让用户请求失败。一、这个包到底负责什么不负责什么llmkey包的职责边界在包注释里写得非常明确internal/ai/llmkey/llmkey.go它负责铸造、读取、退役网关用来识别某个账号的凭证以及把凭证交给调用方的解析器。它本身不发起任何模型调用。真正花钱的克隆体在internal/platform/llm的Client.As也就是AsCaller同一个包里的Bindbind.go是按用户归属的调用唯一解析入口。这个包的历史位置也值得知道它原本是internal/api/handler内部的userLLM只在 Fiber handler 里可用。当cmd/auto-apply成为第二个拥有同样所有权结构的调用方队列行携带的是某个具体候选人自己的申请auto_apply_queue.user_id它没有理由去 import 整个internal/api/handler于是这段逻辑被提取成独立包。这是一次按证据重定位而不是架构洁癖。一个可以直接对照的事实cmd/server在 cmd/server/main.go 构造llmkey.Newcmd/auto-apply在 cmd/auto-apply/main.go 构造同样的东西并加上NewResolver。只有这两个二进制被允许 import 这个包。二、四条永远成立的不变量AGENTS.md的 Always true 一节是整个设计的地基逐条对照源码如下。2.1 凭证对用户不可见没有任何页面、没有任何字段、没有任何设置项也不会出现在任何 API 响应里。它在该账号第一次发起 AI 调用时才被惰性铸造lazy mint。这解释了为什么/me/usage的处理器只读不铸usageHandlers.usage调用的是h.keys.Stored(...)而不是Forinternal/api/handler/me_usage.go、#L60-L80。如果在一个看看自己用了多少的页面里铸造密钥那么每个出于好奇点开页面的访客都会拿到一把密钥有多少账号有密钥这个指标就会从谁花过钱退化成谁看过页面。2.2 归因永远不能让调用失败无法铸造的凭证、连不上的管理 API、读不出的数据库、被网关拒绝的密钥——全部回落到服务凭证。Resolver.For的签名就是这个决策的物理形态它返回而不是 errorinternal/ai/llmkey/resolver.go。注释说得很直白这样调用点写client.As(resolver.For(ctx, userID), tag)就能在所有情况下都正确而不需要记得处理任何分支。归因只是记账丢一条报告记录比丢一个用户要的答案便宜得多。对应到bind.goBind与Caller同样不可能失败internal/ai/llmkey/bind.go。2.3 无人拥有的工作保留服务凭证一条目录里的招聘岗位没有主人。把它的富化enrichment归因给恰好触发了这次运行的人就等于把一笔成本记在一个没产生它的人头上——而那正是这个包存在的意义所要避免的谎言。这条规则被结构性强制而不是靠自觉scope_test.go用go/parser解析 import遍历cmd、internal/ai/enrich、internal/ingest/telegram、internal/application/mailclassify、internal/ai/embed五个根目录任何.go文件测试文件除外import 了internal/ai/llmkey就测试失败internal/ai/llmkey/scope_test.go。豁免是显式的、具名的不是通配模式// 只有两个拥有真实、具名所有者的二进制可以写这个名字 if strings.Contains(slash, /cmd/server/) || strings.Contains(slash, /cmd/auto-apply/) { return nil }测试里还专门加了一道防空转检查先os.Stat五个根目录任一不存在就t.Fatalf。注释记录了一个真实的教训——包被移动到更深的目录后硬编码的../深度直接找不到cmd/再往上一层就会遍历一棵空树然后通过。第三个后台二进制需要自己的一行豁免和自己的理由。2.4 未配置是正常部署不是降级部署LLM_ADMIN_URL、LLM_ADMIN_USERNAME、LLM_ADMIN_PASSWORD、LLM_ADMIN_TEMPLATE_KEY中任意一个未设置New就返回nil什么都不铸造所有调用照旧走LLM_API_KEY。这同时也就是回滚方案。TestNewReportsUnconfiguredAsNilinternal/ai/llmkey/llmkey_test.go对四个字段逐一剥离验证func New(cfg Config) *Client { if cfg.BaseURL || cfg.AdminUsername || cfg.AdminPassword || cfg.TemplateKey { return nil } cfg.BaseURL strings.TrimSuffix(cfg.BaseURL, /) return Client{cfg: cfg, http: http.Client{Timeout: requestTimeout}} }模板密钥之所以算作配置的一部分理由很硬没有它铸造会成功但产出的凭证被所有 provider 拒绝——一个看起来像模型故障、实际是少了一个环境变量的线上事故。缺失比微妙地损坏要好。三、端点不在你会猜的地方管理面在/api下推理面在/v1下。这个客户端发出的每一次调用都走这几个路由internal/ai/llmkey/llmkey.go用途方法 路径认证方式读取模板策略GET /api/governance/virtual-keys/{id}HTTP Basic管理员铸造凭证POST /api/governance/virtual-keysHTTP Basic管理员停用凭证PUT /api/governance/virtual-keys/{id}HTTP Basic管理员删除凭证DELETE /api/governance/virtual-keys/{id}HTTP Basic管理员读取用量GET /api/logs/stats?...HTTP Basic管理员三点必须记住认证是HTTP Basic 的管理员身份不是 bearer tokenLLM_ADMIN_URL独立配置永不由LLM_BASE_URL派生。TestLoad_GatewayAdminIsNotDerivedFromTheInferenceURLinternal/platform/config/config_test.go就是这条规则的守门人。派生会编码一个关于运维是怎么写 URL 的猜测同时放弃了更好的部署姿态管理 API 可以不在公网主机上暴露路径写成一个常量keysPath因为三个动词里任何一个拼错都会返回 404而Block与Delete都把 404 读作已经做完了。仓库的变更记录显示这部分端点形状来自网关迁移openspec/changes/move-llm-gateway-to-bifrost/proposal.md说明四个端点移到/api/governance/...与/api/logs/stats认证从 bearer 改为 HTTP Basic旧的LLM_ADMIN_KEY被LLM_ADMIN_USERNAME/LLM_ADMIN_PASSWORD/LLM_ADMIN_TEMPLATE_KEY取代。四、一个凭证是两段值而且两段都是承重的type Credential struct { ID string Secret string }secret是 bearer token是模型调用唯一需要的东西id是不透明的是 block 和 delete 唯一接受的东西——拿 secret 去问它们会得到 404。只存其中一个会得到一把能花钱但永远无法撤销的凭证而这恰好是账号注销必须做的事。两段在同一条语句里被写入和清除users.llm_key、users.llm_key_id。看 SQLinternal/platform/db/queries/llm_keys.sql-- name: ClaimUserLLMKey :one UPDATE users SET llm_key sqlc.arg(llm_key), llm_key_id sqlc.arg(llm_key_id) WHERE id sqlc.arg(id) AND llm_key IS NULL RETURNING coalesce(llm_key, )::text AS llm_key, coalesce(llm_key_id, )::text AS llm_key_id;ClearUserLLMKey同样是两列一起清空UPDATE users SET llm_key NULL, llm_key_id NULL WHERE id sqlc.arg(id) AND llm_key sqlc.arg(llm_key);注释解释了为什么守卫只放在llm_key上这一列才决定这个账号到底有没有凭证两列都守卫会让 0119 之前的行secret 有、id 为 null被读成未认领并被覆盖从而孤儿化一把还在花钱的密钥。4.1 0119 之前铸造的行是真实状态不是缺陷migration 0067_user_llm_key.sql 只加了 secret 一列因为当时的网关按 secret 寻址block/delete 都接受{key: sk-...}。后来的网关改成按不透明 id 寻址0119_user_llm_key_id.sql 因此只加了一个可空列和一个 UNIQUE 约束没有回填这些行继续可以推理模型调用只需要 secret第一次被拒绝时它们走已有的忘记并重新铸造forget-and-re-mint路径被替换成一对完整值。测试把这两件事都锁住了TestForgetClearsAnIdlessRowWithoutBlocking确认空 id 的行会被清空但不会向网关发请求TestRevokeIsANoopWithoutAnId覆盖从未铸造和0119 之前铸造两种情形internal/ai/llmkey/resolver_test.go。GetUserLLMKey用coalesce把NULL折成让未铸造只有一个信号——调用方每次模型调用都去解pgtype会很啰嗦判断空字符串就够了。五、没有 provider 策略的密钥是死密钥这个网关对没有provider_configs的虚拟密钥拒绝所有 provider。因此不带策略地铸造会产出在第一次调用就被拒的凭证。Mint的做法是读LLM_ADMIN_TEMPLATE_KEY指定的那把虚拟密钥把它的策略复制过来internal/ai/llmkey/llmkey.gopolicy, err : c.policy(ctx) ... body : map[string]any{ name: namePrefix strconv.FormatInt(userID, 10), description: per-user spend attribution, is_active: true, provider_configs: policy, }这层间接是重点。另一种方案是把 provider 列表放进本服务的配置里那会把 provider 词汇表放在两个地方并让新增一个 provider变成一次 freehire 的部署而不是网关自身配置的一次编辑。包里的providerConfig结构只做透传注释明确写着本包不认识任何 provider 的名字。5.1 那个必须记住的不对称key_ids读 null、写[*]for i : range configs { if len(configs[i].KeyIDs) 0 { configs[i].KeyIDs []string{*} } }读接口把key_ids答成null写接口要求[*]把读到的 null 原样回传会铸出一把没有绑定任何 provider key的密钥——它能通过认证然后在每次调用上失败。TestMintNormalisesTheKeyAllowlistTheTemplateOmits用一份带key_ids:null的模板响应验证了归一化结果internal/ai/llmkey/llmkey_test.go。另外模板策略为空时铸造必须失败而不是产出一把死密钥policy中len(configs) 0直接返回包装了ErrUpstream的错误。这样误配置在网关侧就暴露出来而不是表现为每个用户的 AI 悄悄坏了。5.2 铸造请求体的完整字段语义字段来源说明namenamePrefix userID形如freehire-user-42是账号 id 在网关侧唯一出现的位置description常量per-user spend attributionis_active常量true新键立即可用provider_configs模板复制provider、weight、allowed_models、key_ids 原样透传budgets[].max_limitConfig.MaxBudget仅在 0 时下发budgets[].reset_durationConfig.BudgetWindow仅在预算存在且窗口非空时下发rate_limit.request_max_limitConfig.RPMLimit仅在 0 时下发窗口固定1m零值不下发是硬规则显式发送 0 会被网关读作什么也不允许而不是没有限制。TestMintSendsNoCeilingWhenNoneIsConfigured与TestMintPassesAConfiguredCeilingThrough分别锁住两种情形。5.3 2xx 但只有半把凭证 → 报错if minted.Value || minted.ID { return Credential{}, fmt.Errorf(%w: minted an incomplete credential, ErrUpstream) }当客户端与网关之间有东西改写响应时会出现 2xx 但字段缺失的情况。存下半把凭证会把账号永久标记为已有凭证同时让它不可用或不可撤销。envelope结构同时兼容两种应答形状顶层带 key或被virtual_key包裹读取成本低于依赖哪一种。六、编辑模板不会触达已铸造的密钥这是真实事故**策略复制只发生一次在铸造时。**之后没有任何东西重读模板。因此修改模板的provider_configs只影响新账号所有已存在的freehire-user-*密钥保持它出生时的策略。文档记录了一次真实故障2026-10-18模板把flagship移到了 openai但 8532 把用户密钥仍然只允许它在 zai 上当 zai 失去flagship别名那天每一轮对话都以400 Unknown Model结束。用服务密钥测试看不出问题因为助手用的是用户的密钥。6.1 重同步操作步骤以管理员身份对/api/governance/virtual-keys先备份完整列表GET到文件。从模板构造请求体{provider_configs: [...]}每个条目的key_ids设为[*]就是上面的 null 陷阱。只对(provider, allowed_models)集合与模板不同的freehire-user-*密钥执行PUT。逐个比对每个PUT响应与发出去的内容。网关会回 updated successfully然后仍然丢掉某个字段。8 路并发时约四分之一的写入会返回 HTTP 500。跑 1–2 路并发重复整轮直到没有任何差异。重跑是免费的因为第 3 步会跳过已经匹配的密钥。6.2 排查助手故障的正确姿势用用户自己的密钥复现即users.llm_key里的值而不是服务密钥读网关的/api/logs其中virtual_key_name会告诉你失败的调用用了哪把密钥Langfuse看不到助手轮次。没有 worker 做自动重同步这是一个明确判断模板很少变一个几乎每次都什么都不改的每小时任务不值得它的成本。七、配置开关与代码装配配置读取在 internal/platform/config/config.goLLMAdminURL: os.Getenv(LLM_ADMIN_URL), LLMAdminUsername: os.Getenv(LLM_ADMIN_USERNAME), LLMAdminPassword: os.Getenv(LLM_ADMIN_PASSWORD), LLMAdminTemplateKey: os.Getenv(LLM_ADMIN_TEMPLATE_KEY), LLMUserMaxBudget: envFloat(LLM_USER_MAX_BUDGET, 0), LLMUserRPMLimit: envInt(LLM_USER_RPM_LIMIT, 0), LLMUserBudgetWindow: env(LLM_USER_BUDGET_WINDOW, 30d),环境变量默认作用未设置时LLM_ADMIN_URL空管理 API 根地址如https://gw.example整体关闭归因LLM_ADMIN_USERNAME空管理员用户名Basic 认证整体关闭归因LLM_ADMIN_PASSWORD空管理员密码整体关闭归因LLM_ADMIN_TEMPLATE_KEY空提供 provider 策略的模板虚拟密钥 id整体关闭归因LLM_USER_MAX_BUDGET0每个用户密钥的预算上限不下发预算字段LLM_USER_RPM_LIMIT0每个用户密钥的每分钟请求上限不下发限流字段LLM_USER_BUDGET_WINDOW30d预算重置周期无预算时无意义两个细节值得强调LLM_USER_MAX_BUDGET若写成two dollars这类无法解析的值会被读成0无上限而不是 0 预算。TestLoad_AnUnparseableCeilingIsNoCeiling的注释是一个环境变量里的笔误不应该拒绝这个部署发出的每一次调用。仓库里另有多处注明LLM_USER_MAX_BUDGET刻意保持不设置例如 internal/api/handler/mail_recall_limit.go 附近因为单次邮件召回会把多封正文放进一次模型调用一刀切的预算上限会误伤正常使用。装配点服务端 cmd/server/main.go 把Settings的七个字段直接映射到llmkey.Configcmd/auto-apply做同样的事并额外构造NewResolver(queries, llmKeys)。八、Resolver铸造、读取、忘记、撤销8.1For—— 返回而不是错误func (r *Resolver) For(ctx context.Context, userID int64) string { if r nil || r.gateway nil || r.q nil { return } stored, ok : r.read(ctx, userID) if !ok { return } if stored.Secret ! { return stored.Secret } return r.mint(ctx, userID).Secret }read返回两个答案凭证以及存储到底能不能读resolver.go。这是刻意区分的pgx.ErrNoRows→ 返回(Credential{}, true)即一个真实存在但还没有凭证的账号可以铸造其他 error → 记录日志并返回(Credential{}, false)即数据库没答话绝不可以铸造。原因很直接在数据库故障期间铸造会给每个请求发一把新密钥每一把都存不下来每一把都留在网关上。TestForFallsOpenWhenTheStoreCannotBeRead断言For返回且minted 0resolver_test.go。8.2mint—— 竞争的处理方式两个并发的首次调用都会铸造。条件认领WHERE id $1 AND llm_key IS NULL配合行锁只放行一个输家采用赢家的值并删掉自己铸造的那把否则它会永远留在网关上不花钱却出现在每一个列表里。claimed, err : r.q.ClaimUserLLMKey(ctx, ...) switch { case err nil: return Credential{ID: claimed.LlmKeyID, Secret: claimed.LlmKey} case errors.Is(err, pgx.ErrNoRows): r.revoke(ctx, minted.ID) // 输家清理自己 winner, err : r.q.GetUserLLMKey(ctx, userID) ... default: r.revoke(ctx, minted.ID) // 存不下 → 立刻删 return Credential{} }revoke用的是Delete尽力而为失败只记一行日志因为铸造竞态的输家是一把秒级、从未被存储的凭证没有任何值得保留的东西。测试覆盖了三类TestForLosesTheRaceAndRevokesItsOwnKey输家被删、TestForRevokesACredentialItCouldNotStore认领报错时删除、TestClaimNeverStoresAnEmptyCredential空 secret 是未铸造信号绝不能写进去且不得发起认领。8.3Forget—— 网关拒绝之后func (r *Resolver) Forget(ctx context.Context, userID int64, secret string)顺序是先清行、后停键每一步都有原因先read。要按 id 去停用而即将被清空的行是 id 唯一被写下的地方先清空会留下一把没人能命名的活密钥。因此读失败时整个操作停止只留日志TestForgetLeavesTheRowAloneWhenItCannotBeRead。再ClearUserLLMKey条件带上我们以为存着的那个 secret。并发调用可能已经重铸无条件清空会丢掉那把好凭证并把它孤儿化在网关。只有当行里的 secret 仍然等于传入的 secret且 id 非空时才调用gateway.Block。否则例如并发的Revoke刚刚停用了它什么都不做。ForgetBlock 而不是 Delete一次拒绝在两种情形下看起来完全一样——网关确实忘了这把密钥常见情况或者同一账号的注销刚刚把它停用了。后者有值得保留的花费历史Delete 会偶发地抹掉它。而 Block 一把已经停用或不存在的密钥是安全的空操作。8.4Revoke—— 账号注销func (r *Resolver) Revoke(ctx context.Context, userID int64) error { if r nil { return nil } stored, ok : r.read(ctx, userID) if !ok { return fmt.Errorf(%w: cannot read the credential to revoke, ErrUpstream) } if stored.ID { return nil } return r.gateway.Block(ctx, stored.ID) }与包内其他地方不同这里读失败必须返回错误别处读失败只损失归因这里会让注销操作报告成功却什么都没撤销留下一把仍在为一个已不存在的账号花钱的活密钥。而删除账号行正是让 id 不可恢复的那一步所以这是任何人最后一次能行动的时刻。行本身故意不动它即将被账号级的级联删除清掉先清只是多一次写。网关侧留下的是一个被停用、标着内部数字 id 的密钥——账号行消失后这个 id 映射不到任何人。注销链路在 internal/identity/accountdelete/accountdelete.go 中通过WithGatewayKeys(llmKeys.Revoke)接入见 internal/api/handler/handler.go和 Google 授权的撤销一样是尽力而为离开的成员必须停止花钱但不必等一个第三方系统。九、Block 与 Delete 的分工动作方法语义适用场景BlockPUT .../{id}{is_active: false}停止花费保留在网关列表中账号注销Forget清理被拒凭证DeleteDELETE .../{id}彻底抹掉铸造竞态的输家无法再被任何东西命名的密钥文档里诚实地记录了理由的弱化用量日志按凭证 id 索引并且比凭证本身活得更久所以删除不再像以前那样抹掉历史。Blocking 现在买到的更多是可读性——一把退役的密钥仍然留在网关的列表里看得出它曾经是什么。两者都把 404 当作已经完成Block/Delete里errors.Is(err, ErrUnknownKey)返回nil因为那正是调用方想要的状态注销流程不能因为到达该状态就记一条故障。十、401 的两种含义绝不能混为一谈按用户调用上的 401网关忘了这把密钥 → 该重铸管理调用上的 401我们自己的管理员密钥错了。混淆二者会让一个打错的环境变量被读成每个账号的密钥都过期了从而触发一场重铸风暴。这是通过结构而不是哨兵值分开的do只把 404 分类为ErrUnknownKey其余所有非 2xx包括 401都进通用的ErrUpstream分支llmkey.go。TestARefusedAdminCallIsNotAnUnknownUserKey就是这条规则的断言。真正要紧的那个 401——网关在推理调用上拒绝用户凭证——发生在internal/platform/llm的传输层attribution.RoundTrip收到 401 且存在 fallback 时用服务凭证重放一次且只重放一次同时调用onRefused也就是Resolver.Forget把那一行清掉下次调用重铸internal/platform/llm/credential.go。两条容易忽略的细节重放会重新stamp维度标签。一次丢失维度的重试会把花费记在什么都没有下面而重试恰恰是后来有人会问发生了什么的场景。fallback 永远是真实的服务凭证绝不会是另一个用户的 secret。如果c.fallbackKey已存在客户端已绑定过某用户它被原样继承否则链式base.As(userA).As(userB)会让 userB 的 fallback 指向 userA 的密钥一次拒绝就会静默地在 userA 名下继续花钱。十一、绑定入口Bind与Callerbind.go只有两个导出函数但它们承载了一整条规则。func Bind(ctx context.Context, keys *Resolver, client *llm.Client, userID int64, dims ...llm.Dimension) *llm.Client { if client nil { return nil } return client.AsCaller(Caller(ctx, keys, userID, dims...)) }Bind的用途是调用方还没有自己的客户端准备基于这个构建一个。已经持有配置好的客户端的调用方不能走这里——它会被换成一个不同的客户端丢掉那份配置。这类调用方用Caller拿到身份然后应用到自己的客户端上。这不是理论洁癖。Caller的引入直接来自一次线上代价结构化简历提取声明了 120 秒预算resumeExtractLLMTimeout却对每个登录用户跑在 90 秒默认值上——足够在一份长简历上超时让候选人既没有落库的经历、也没有匹配分析、也没有任何解释。llm.Caller只携带谁内部没有客户端可以被替换所以调用点不可能再犯这个错误。internal/api/handler/user_llm.go里llmBinding.bind/llmBinding.caller就是这两个函数之上的薄封装而不是第二套实现。Caller的另一个关键点forget : func() { keys.Forget(context.WithoutCancel(ctx), userID, secret) }清理运行在脱离调用方 ctx 的上下文上。拒绝最可能恰好在用户关掉标签页时到来对队列触发的调用方则是运行自身 deadline 已过之时而一个被网关拒绝的密钥此时也必须被清掉否则该账号之后每次调用都在为一次被放弃的请求付账。TestBindForgetsARejectedCredentialAfterCancellation用cancel()之后仍发起的GenerateJSON验证了这一点bind_test.go。十二、特性标签feature dimension的写法标签由llm.Feature(value)构造最终变成请求头x-bf-dim-feature: valueinternal/platform/llm/credential.go。三条规则值是裸词不带feature:前缀。头部名称已经指明了维度值里再带前缀就会把花费归档到它自己那个两段式标签下而不是与其他人并列。一件事一个标签不要给两个面向不同用户的界面用同一个标签否则报告不再回答它存在要回答的问题。标签常量放在调用方旁边请求驱动的界面在 internal/api/handler/user_llm.goassistant、match-analysis、cv-extract、ats-review、autofill、mail-recall、search-intent、cover-letter队列驱动的自动投递在 internal/api/atsapply/client.gotagAutoApplyDrafting auto-apply-drafting。CV 定制tailoring刻意没有自己的标签它不发起自己的模型调用端点是铸造一份 CV 并扣积分实际工作是一个tailorpreset 下的助手轮次——已经带标签了。第二个标签会重复计算同一笔花费。一个必须遵守的写法约束空名字或空值的维度会被stamp跳过而不是发送。x-bf-dim-: value不是一个网关能分组的头空值与缺失在远端无法区分。十三、按用户调用的第五步新增功能时的检查清单AGENTS.md的 Adding a per-user LLM call 给出五条可以直接当 code review checklist通过Bind解析bind.go——这是唯一需要 grep 的标识符。internal/api/handler的llmBinding.bind只是每请求的薄封装不是第二份实现。给它一个 feature 标签常量放在调用方旁边。裸词、一表面一标签。只有cmd/server与cmd/auto-apply可以 import 这个包——scope_test.go结构性强制。新的二进制要按名字把自己加进豁免表并附上理由检查故意不是一个会意外放行的模式。如果组件在构造时就持有客户端给它一个一行的As(*llm.Client)克隆而不是把第二个客户端穿进构造函数。在开流之前解析。铸造是一次网络调用超时上限 5 秒requestTimeout在响应头已经发出之后再铸造会让客户端正在读取的流卡住。第 1 条之外还有一层服务端的护栏llmBinding.bind/caller失败关闭fail closed——上下文未被付费 AI 闸门mw.paidAI标记时不会拿到任何客户端返回的是 500 而非升级提示internal/api/handler/user_llm.go。这是把每次用户归因的模型调用都经过这里变成结构事实的另一半。十四、Activity用量是从网关读的不是自己记的花费实际落在网关自己的请求日志里每次调用一行按凭证 id 索引。GET /api/logs/stats在窗口上聚合它——这就是/me/usage读的东西也是本包不写自己的成本表的原因。window : url.Values{ virtual_key_ids: {keyID}, start_time: {from.UTC().Truncate(24 * time.Hour).Format(time.RFC3339)}, end_time: {to.UTC().Truncate(24 * time.Hour).Add(24*time.Hour - time.Nanosecond).Format(time.RFC3339Nano)}, }四个被测试钉死的实现细节两次调用不是一次。网关报的是成功率而不是失败数反推回去要做四舍五入9 次里失败 7 次是 77.77…%把显示值还原成2 次失败是在对展示数字做算术。第二次调用把同样的窗口加上statuserror过滤直接读计数。窗口两端扩到整天。调用方按日期工作网关按时刻工作照字面取 from/to 会静默丢掉第一天更早、最后一天更晚的一切。结束时间是当天最后一个瞬间...T23:59:59.999999999Z而不是 23:59:59。空 id 直接短路为零不问网关——这既是从未用过 AI 的账号也是0119 之前的行。不返回任何金额。Activity只有Requests / Failed / Tokens。网关的成本数字是面对混合上游池的标价不是我们的成本更不是用户的价格用户的价格用积分计量。14.1 读这个数字前必须知道的两个后果读取只作用于一把凭证。网关没有按账号查询的能力所以一把月中被替换的密钥会让它之前的调用掉出总计。重铸只在网关拒绝已存密钥时发生所以罕见修复方案记住已退役 id 的列表会让死凭证长期存活只为让一个计数器稍微完整一点。日志写入是异步的大约在调用后 5 秒落地。跨月看无害任何 read-after-write 假设都是致命的。usageHandlers.usage用plan.Day(now)与plan.ResetsAt(now)取周期和配额重置周期同源避免配额和用量按不同周期统计两个看起来都对却互相矛盾。14.2 按功能拆成本不是 API 问题feature维度会进日志行、进 OpenTelemetry span并且——只有当feature被列在网关自己的prometheus_labels里时——才会进指标。定制一份 CV 花多少钱因此是一个 Grafana 查询/api/logs/histogram/cost/by-dimension只按 provider、team、customer、user 和 business unit 聚合。再强调一次边界**这些数字是标价不是账单。**网关按它的模型表定价而它背后的池子跑着混合的上游密钥。它们可以诚实地比较不同功能和不同周期绝不能当作发票引用。十五、验证这套机制可以跑哪些测试包内的测试不需要外部依赖全部用httptest假网关 fakeQueriesgo test ./internal/ai/llmkey/... go test ./internal/platform/config/ -run LLM|GatewayAdmin|Ceiling阅读顺序建议先看llmkey_test.go客户端与路由形状、模板策略、半凭证、Activity 窗口再看resolver_test.go竞态、读失败、忘记与撤销最后看bind_test.go——它用一个spendProxy记录真实发出的Authorization和X-Bf-Dim-Feature因为客户端自己的字段报告的是它相信的东西而不是它发出去的东西bind_test.go。想验证结构性约束本身go test ./internal/ai/llmkey/ -run TestBackgroundEntryPointsResolveNoUserCredential -v如果想验证一次改动没有引入漏归因的调用grep 这两个标识符就够了llmkey.Bind(与llmkey.Caller(新增的后台二进制若出现在cmd/下scope_test.go会先把构建拦住。结语internal/ai/llmkey的规模不大但它几乎每一行都在处理归因是记账不是功能这条前提带来的后果nil客户端是正常部署、For返回空串而不是错误、数据库读不出时不铸造、401 的管理面与推理面彻底分开、已花费的凭证停用而不是删除、模板编辑后必须手工重同步。把这些规则当成契约来读你就能在这个仓库里安全地新增一个按用户计费的模型调用而把它们当成可选的风格建议你已经看到了后果——8532 把用户密钥在某个 10 月的一天集体变成400 Unknown Model。赞分享【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire 的 AI 成本治理设计每用户 LLM 虚拟密钥、三档每日额度与 402 拒绝指南freehire 的 AI 成本治理设计每用户 LLM 虚拟密钥、三档每日额度与 402 拒绝指南 freehire 是一个开源职位搜索引擎它从公司自己的H2O LLM Studio 因果回归实验卡片regression_head 推理用法与模板变量机制全解H2O LLM Studio 因果回归实验卡片regression_head 推理用法与模板变量机制全解 本文围绕 H2O LLM Studioh2o ll微调大模型AI 应用用 PostHog 探索 LLM 成本从总花费、模型/用户/链路分解到回归告警的完整实战指南用 PostHog 探索 LLM 成本从总花费、模型/用户/链路分解到回归告警的完整实战指南 PostHog 会在采集ingestion阶段为每一条 $a数据分析数据可视化后端前端可观测性上一篇ADK A2A 实战从零跑通分布式智能体协作把孤岛智能体连成网络下一篇GEV-26B-Decide 评测报告Decision Index 62.48分背后的10大基准拆解创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED

相关推荐

YimMenu 完整指南:GTA5 防崩溃菜单的功能、编译与 Lua 扩展教程

YimMenu 完整指南:GTA5 防崩溃菜单的功能、编译与 Lua 扩展教程

YimMenu 完整指南:GTA5 防崩溃菜单的功能、编译与 Lua 扩展教程 【免费下载链接】YimMenu YimMenu, a GTA V menu protecting against a wide ranges of the public crashes and improving the overall experience. 项目地址: https://gitcode.com/GitHub_Trendin…

📅 2026/10/23 6:26:50
agents-md - SKILL

agents-md - SKILL

name: agents-md description: This skill should be used when the user asks to “create AGENTS.md”, “update AGENTS.md”, “maintain agent docs”, “set up CLAUDE.md”, or needs to keep agent instructions concise. Enforces research-backed best practices for…

📅 2026/10/23 6:21:50
agents-generator - decision-matrix

agents-generator - decision-matrix

决策矩阵——项目检测与规则选择 检测顺序 按此顺序运行检测。每个步骤读取文件并设置后续步骤使用的标志。 1. 包管理器 检查锁文件:bun.lock → bun,pnpm-lock.yaml → pnpm,package-lock.json → npm,yarn.lock → yarn。 2. 项…

📅 2026/10/23 6:21:50
MORE NEWS

更多资讯

📰

嵌入式驱动开发实战:从寄存器操作到DMA调试的完整经验总结

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

时序路径分析核心框架:从STA原理到违例修复实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

智能制造生产优化的5大落地难点:数据断层、多目标冲突与人机协同

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

采样间隔1秒一天86KB?GOT2000数据记录容量配置全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Loop 开源窗口管理工具:鼠标手势与快捷键提升 macOS 多窗口效率

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

如何降低AI检测率?让AI写作更有“人味儿”的实用技巧

“怎么降低AI检测率”这个话题,最近几个月在内容创作者圈子里几乎成了默认的刚需。无论是写自媒体文章、做短视频文案,还是提交一份项目总结报告,很多人发现:自己明明花了心思写东西,怎么一放进AI检测软件里就显示“疑…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬