GitHub - tokenmp/core · GitHub
Skip to content
tokenmpPublic

About

No description, website, or topics provided.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

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, 任意两种之间都能转换。

Claude Messages 另有一个计数端点,路径是 POST /v1/messages/count_tokens, 请求体与 Messages 同形,只少了 max_tokens。它在服务端按模型算, 不在本地估算,因此转发到上游取结果:只有 claude 方言的候选渠道服务得了, 模型的候选里一条这样的渠道都没有时回 501,错误类型是 not_supported。 客户端据此自己降级,这是它本来就会做的事。这个端点不参与渠道亲和: 计数不写上游缓存,粘住它没有意义;上游给出的错误照原样透传。

Outcome.Path 是计数端点自己的路径,不是 Messages 入口那条。记录侧按这个 路径区分入口,记成 Messages 入口会留下一行没有 token 数的调用记录,且不报错。

起一个服务

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 里,多账号写成 auth.accounts 引用顶层 accounts 里的账号, 加载时各自展开成一条渠道、填好它自己的 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。 两个耗时字段的精度就是宿主平台单调时钟的粒度,不另行记录:Linux 上是纳秒, Windows 上只有系统时钟中断的粒度(0.5 到 15 毫秒)。粗粒度平台上短于一个刻度的 等待会量成 0:0 只说明这一次没测出来,不代表请求失败、也不代表没写响应体。 要分辨「整个请求没有写出过响应体」,看同一个 server.Outcome 里的 BytesWritten 是不是 0。
  • Usage 在上游给出用量时非空。跨方言转发由转换层给,同方言的流式透传由内核 从上游分片里取(OpenAI 系两种方言);同方言的非流式透传响应字节原样返回, 用量另做一次预筛取得,上游没给时仍为 nil。
  • 回调同步跑在请求的 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 没有隶属关系。

About

No description, website, or topics provided.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages