core
TokenMP 的转发内核,Go 库,模块 github.com/tokenmp/core。
它对外暴露四种大模型文本协议的端点,对内转发到配置好的上游渠道,
跨协议时转换,并按配置加载本地 JavaScript 插件。
同方言转发时不重建报文,报文字节原样送上游;只有规则写了 upstream 且与
客户端模型名不同时,才用定点改写把顶层 model 换成上游名,其余字节不动。
跨方言才解码重建。诊断与用量沿用 RelayKit 的类型,见
转换内核 的「与 RelayKit 的关系」。
本仓库不产出可执行文件。
命令行与管理界面各自有仓库,它们按 tag 依赖这里的公开包。
包
| 包 |
职责 |
| dialect |
四种方言的标识、路径形状,以及请求、响应、流式分片的编解码 |
| relay |
转换内核:同方言字节透传,跨方言请求、响应、流式转换 |
| channel |
上游渠道:基址拼接、鉴权样式、超时、重试、SSE 读写 |
| plugin |
插件宿主:编译、加载、四个钩子、限额、检查与 fixture 回放 |
| config |
YAML 配置模型、加载、校验与路由解析 |
| server |
HTTP 服务:把上面五个包装配起来并监听 |
依赖单向向下。
server 依赖其余全部,config 只依赖 dialect,relay 依赖 dialect。
公开面一旦发布就是兼容承诺。
支持的四种方言是 OpenAI Chat Completions、OpenAI Responses、
Anthropic Claude Messages 与 Google Gemini generateContent,
任意两种之间都能转换。
起一个服务
package main
import (
"context"
"log/slog"
"github.com/tokenmp/core/config"
"github.com/tokenmp/core/server"
)
func main() {
cfg, err := config.Load("config.yaml")
if err != nil {
panic(err)
}
logger := slog.Default()
service, err := server.FromConfig(cfg, logger)
if err != nil {
panic(err)
}
if err := service.Run(context.Background()); err != nil {
panic(err)
}
}
装配要用的渠道凭据从环境变量读,变量名写在配置的 auth.key_env 里。
服务给每个进入的请求记两行访问日志(进入与完成),用传进来的 logger 写出,
级别按配置的 log.level 定。字段与级别口径见配置规范的「访问日志」一节。
宿主想在每次请求结束时记自己的指标,用 WithRequestDone 拿原始量,
回调带本次请求一直沿用下来的 context 与 Outcome:
service, err := server.FromConfigWith(cfg, logger, server.WithRequestDone(func(ctx context.Context, outcome server.Outcome) {
// 口径由宿主定,core 只交原始量。
logger.Info("请求结束",
"ttft", outcome.FirstByte,
"duration", outcome.Duration,
"bytes", outcome.BytesWritten,
"streaming", outcome.Streaming,
"attempts", len(outcome.Attempts))
}))
Outcome 带首字节时间、写出字节数、每次候选尝试的耗时与状态、用量、模型与渠道。
三件事要留意:
FirstByte 是首次写出响应体的时长。流式请求里它就是要算的 TTFT;
非流式响应是一次写完的,这个值约等于 Duration,看不出 TTFT。
Usage 只在跨方言转发且上游给出用量时非空。同方言透传按字节转发、不解析,
流式响应的 TPS 因此算不出来。
- 回调同步跑在请求的 goroutine 上,宿主在里面做的耗时工作会算进客户端等待时间里。
回调返回之后,net/http 会取消这个请求的 context,别在回调里开依赖它的 goroutine。
它 panic 会被吞掉并记一行 ERROR,不连累别的请求。
宿主想自己接管记录,把 WithMiddleware 与 WithRequestDone 两个口子都用上:
service, err := server.FromConfigWith(cfg, logger, server.WithoutAccessLog(),
server.WithMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 中间件装在外侧:它加进 context 的值会一路传下去。
ctx := context.WithValue(r.Context(), myStateKey{}, startTiming(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
}),
server.WithRequestDone(func(ctx context.Context, outcome server.Outcome) {
// 回调是这个缺口的唯一数据入口,带 ctx 与这次请求的原始量。
logger.Info("请求结束",
"request_id", outcome.RequestID,
"channel", outcome.Channel,
"ttft", outcome.FirstByte,
"attempts", len(outcome.Attempts))
}))
OnRequestDone 是宿主拿到内核内部数据的唯一入口,每次请求结束时同步调用,
参数是本次请求一直沿用下来的 context 与 Outcome(渠道、每次尝试、用量、
首字节时间、写出字节数)。中间件装在整个服务(含访问日志)的外侧,负责 HTTP
层的控制:加响应头、换请求体、自己裹 ResponseWriter、往 context 里放请求级的
记账状态。
宿主按请求给上游加头,在同一个中间件里调 WithUpstreamHeader:
server.WithMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := server.WithUpstreamHeader(r.Context(), "X-Trace-ID", traceID(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
})
它是按请求给上游加头的唯一入口:客户端自带的请求头不会被转发,因此只有在这里
明确设置的头才会上去。与渠道配置里的同名头按请求覆盖,请求级比渠道级更具体,
而覆盖是显式的,宿主没设的同名头照旧上去。覆盖是按名字整个替换,渠道配置里的
那条就不发了,宿主看不到它的原值,两个值都想要得自己拼。渠道的鉴权头由 Auth
最后写入,宿主设的同名头不生效,凭据始终来自渠道。同一个名字重复设置时后一次生效。
WithoutAccessLog() 关掉内核自己那两行,避免与宿主的记录重复;关掉之后进入行里的
client 与 user_agent 也一起没有,需要的话从 Outcome 的同名字段自己记。
几个边界要知道:
- 包装
http.ResponseWriter 时必须把 Flush 转发下去,
否则流式响应会静默退化成不刷新。内核只统计经传给 next 的 writer 写出的字节,
宿主在 pre/post 里直接在原 writer 上写、或先设了状态码,不进入 Outcome。
- 中间件不调用
next 时内核整个不参与:没有访问日志、没有回调,
那条请求由宿主自己记账。
- 改请求头不会传到上游 —— 上游请求头来自渠道配置与
Auth,内核不从进来的请求里抄;
要按请求加上游头,用 WithUpstreamHeader。请求体可以换:内核从 request.Body
读,换过之后参与路由的就是新内容。
只用转换内核、不起服务也是支持的:
engine := relay.NewEngine(relay.Options{})
prepared, err := engine.PrepareRequest(ctx, relay.Request{
From: dialect.ClaudeMessages,
To: dialect.OpenAIChat,
Body: body,
})
细节见 转换内核。
配置
格式是单个 YAML 文件,解码开启 KnownFields,拼错的字段在启动时报错。
listen: 127.0.0.1:8787
channels:
- name: openrouter
dialect: openai
base_url: https://openrouter.ai/api/v1
path: /chat/completions
auth:
style: bearer
key_env: OPENROUTER_API_KEY
models:
- match: "gpt-*"
channel: openrouter
upstream: openai/gpt-5
字段清单与校验规则见 配置,
可直接复制的一份在 示例配置。
插件
插件是单文件同步 ECMAScript 模块,放在 plugins/<key>/plugin.js,
导出 meta 与需要的钩子。
export const meta = {
key: "echo",
name: "回显",
version: "0.1.0",
apiVersion: "v1",
};
export function onRequest(input) {
input.body.messages.push({ role: "system", content: "tag=" + host.config.tag });
return { body: input.body };
}
宿主不提供网络、文件系统与动态代码执行,单次调用限时 50 毫秒。
插件失败不影响转发可用性。
契约见 v1.d.ts,运行细节见 插件,
示例见 示例插件。
命令
make check # 门禁:编译、单测(-race)、静态检查、格式检查
make test # 单测
make lint # 静态检查
make tools # 按 .mise.toml 装齐工具链
make check 只依赖 Go 与 golangci-lint,不需要网络、数据库或 node。
边界
本仓库只做本地转发。明确不做的事:
- 不做客户端鉴权,也不做配额与速率限制。默认只监听
127.0.0.1,
对外开放时请自己加一层入口。
- 不做账户、余额、计费与结算,也没有数据库。
- 不做浏览器控制台。
- 不做异步任务类插件(视频、图片、轮询)。四个钩子只处理文本请求与响应。
渠道亲和
同一个模型配了多条候选渠道时,给这些规则写上同一个 affinity 组名,同一段会话的
连续请求就会粘在同一个渠道上,让上游的提示缓存有机会命中。
客户端肯配合的话,请求头带 X-TokenMP-Session 最准,请求体里的
prompt_cache_key 也行,但它只有 OpenAI 的两个方言有。两个都没带时 core 靠请求
内容认出上一轮发过的前缀,在进程内记着上一轮走了哪条渠道。
亲和默认关闭,要显式写组名才生效。它是偏好而不是钉子,粘住的渠道失败时后面的
候选照旧兜底。细节见配置规范的「渠道亲和」一节。
已知边界
这几条是明确的取舍,不是待办。
重试会重发请求体。上游返回 5xx 时那个请求可能已经被处理了一半,
只有在上游保证按请求幂等时才安全。Retry-After 不参与退避。
插件没有内存上限。moejs 不提供这个开关,限额拦得住失控的插件,拦不住故意吃内存的。
默认不做客户端鉴权。服务只听 127.0.0.1,对外提供时必须自己加一层入口。
许可
本项目按 AGPL-3.0 发布,全文见 LICENSE,不加 -or-later。
转换内核来自 new-api 的 RelayKit,它同样按 AGPL-3.0 发布,
版权与许可列在 NOTICE 与 第三方许可。
AGPL-3.0 第 13 条要求:通过网络与本程序交互的用户,有权拿到对应版本的源码。
把它部署成对外服务时,请一并提供源码位置。
本项目与 new-api、QuantumNous 没有隶属关系。