Compare commits

...

1 Commits

Author SHA1 Message Date
2b456dbb07 4 2026-06-03 14:09:58 +08:00
46 changed files with 7145 additions and 1422 deletions

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# goaiapi — 插件化 AI API 中继
一个**一切皆插件**的 AI API 流量中继。内核只是加载器:负责启动插件、版本管理、依赖解析、插件互调与动态加载/更新。中继的全部业务能力(反向代理转发、用量解析、留存、管理界面)都由独立进程插件实现,经内核 InvocationBroker 用 gRPC 互相调用。
## 设计要点
- **进程隔离插件**:每个插件是独立二进制,经 [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin) 用 gRPC 与内核通信。插件崩溃不会拖垮内核。
- **内核中转互调**插件之间从不直连。A 调 B 时A → 内核 Broker → B请求/响应是 proto-wire 字节,内核不感知任何插件的具体类型。
- **三层插件**(由内核依据依赖图复算校验,不信插件自报):
- **base**:无插件依赖(如 `echo-base``recorder-base``usage-base`
- **mid**:仅依赖 base`transform-mid``dashboard`
- **complex**:依赖至少一个非 base`proxy``streaming-demo`
- **版本管理**:依赖用 semver 约束声明,内核加载时校验已注册版本是否满足,并支持热升级(原子换 + 旧进程延迟排空)。
- **流量路径**:外部 HTTP默认 `:8080`)→ 内核 → Broker → `proxy` 插件 → 上游;`proxy` 再经 Broker 调 `usage-base` 解析、`recorder-base` 留存。
## 目录结构
| 目录 | 说明 |
|---|---|
| `proto/` | **语言中立的 API 契约源**`contract.proto`)。其它语言从此生成客户端 |
| `src/` | 全部源码(见 `src/go/` |
| `scripts/` | 构建与代码生成脚本(`build.ps1``genproto.ps1` |
| `build/` | 编译产物(`relay.exe` + `plugins/`)与运行数据。**不入库** |
| `tools/` | 预留的本地工具目录(当前为空) |
每个一级子目录另有自己的 README。跨语言接入本系统先看 `proto/README.md`
## 快速开始
构建(生成 proto、编译内核与全部插件
```powershell
.\scripts\build.ps1
```
运行(内核默认监听 `:8080` 收流量、`:8082` 控制端口,自动加载 `build/plugins/` 下的插件):
```powershell
.\build\relay.exe -u https://your-upstream.example.com
```
内核入口的命令行参数(见 `src/go/cmd/relay/main.go`
| 参数 | 默认 | 说明 |
|---|---|---|
| `-l` | `127.0.0.1:8080` | 外部流量监听地址 |
| `-pd` | `build/plugins` | 插件目录(扫描 `*.exe` 自动加载;空字符串则不自动加载) |
| `-pa` | `127.0.0.1:0` | 内核 Broker gRPC 地址(`:0` = 随机端口) |
| `-ca` | `127.0.0.1:8082` | 插件管理控制端口(空字符串关闭) |
| `-proxy` | `proxy` | 处理外部流量的代理插件名 |
| `-drain` | `5000` | 热升级时旧插件进程排空等待毫秒数 |
> 上游地址(`-u`)等参数由 `proxy` 插件接收。
## 控制端口 API默认 `:8082`
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/plugins` | 列出已加载插件 |
| GET | `/api/plugins/{name}` | 单个插件详情 |
| POST | `/api/plugins/load` | `{"binary_path"}` 加载注册 |
| POST | `/api/plugins/unload` | `{"name"}` 卸载(被依赖则拒绝) |
| POST | `/api/plugins/upgrade` | `{"name","binary_path"}` 热升级 |
## 工具链
构建依赖外部工具链(不在 PATH 上时脚本内已硬编码路径,按本机实际调整):
- Go 1.26.3
- protoc + `protoc-gen-go` / `protoc-gen-go-grpc`(仅在改 `contract.proto` 时需要;生成代码已入库)
## 主要依赖
`github.com/hashicorp/go-plugin`(插件进程框架)、`google.golang.org/grpc`(互调传输)、`github.com/Masterminds/semver/v3`(版本约束)、`modernc.org/sqlite`(留存索引,纯 Go 无需 CGO

79
proto/README.md Normal file
View File

@@ -0,0 +1,79 @@
# proto — 跨语言 API 契约
本目录是系统的**语言中立契约源**。`contract.proto` 定义了内核与插件之间、以及外部系统与本系统交互所需的全部 gRPC 服务与消息。任何语言都可以从这里生成客户端/服务端代码来接入本系统。
```
proto/
contract/v1/contract.proto 契约源(唯一可信来源)
```
> Go 生成代码位于 `src/go/proto/contract/v1/`(已入库,克隆即可 `go build`)。其它语言的生成代码不入库,由各使用方按需生成。
## 契约内容
`contract.v1` package 定义:
**通用服务(每个插件都实现)**
- `PluginManifest``GetManifest``HealthCheck`
- `InvocationBroker``Invoke`(内核托管,插件互调入口)、`ListPlugins`
**枚举与基础消息**
- `PluginTier``BASE` / `MID` / `COMPLEX`
- `Dependency``Capability``PluginInfo``TrafficEntry``UsageInfo`
**业务服务(由插件实现)**
- `EchoService``TransformService``StreamingDemo` — 样例(验证三层与流式)
- `RecorderService` — 留存与查询Save/List/ReadBlob/Stats
- `UsageService` — 用量解析ParseBody/ParseStream
- `ProxyService` — 外部流量处理入口Handle
- `DashboardService` — 只读管理界面渲染Render
## 跨语言生成示例
先确保安装了 protoc 及对应语言插件,然后从仓库根目录执行(`--proto_path` 指向本目录)。
**Python**
```bash
python -m grpc_tools.protoc \
--proto_path=proto \
--python_out=out/python --grpc_python_out=out/python \
contract/v1/contract.proto
```
**TypeScript / JavaScriptts-proto**
```bash
protoc \
--proto_path=proto \
--plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto \
--ts_proto_out=out/ts \
contract/v1/contract.proto
```
**Java**
```bash
protoc \
--proto_path=proto \
--java_out=out/java --grpc-java_out=out/java \
contract/v1/contract.proto
```
**Go**(已配置在 `scripts/genproto.ps1`,无需手动)
```powershell
.\scripts\genproto.ps1 # 从 proto/ 生成到 src/go/proto/
```
## 互调约定
插件间调用全部经内核 `InvocationBroker.Invoke`
- `target_plugin` — 目标插件名(如 `"echo-base"`
- `method` — gRPC 方法全路径(如 `"/contract.v1.EchoService/Echo"`
- `payload` — 目标请求消息的 proto-wire 字节(用本契约生成的类型序列化)
返回的 `result` 是目标响应消息的 proto-wire 字节,用同一契约反序列化即可。内核不感知具体类型,只透传字节——这正是契约必须语言中立、独立存在的原因。
## 修改契约
`contract.proto` 后:
1. 重新生成 Go 代码:`.\scripts\genproto.ps1`
2. 提交更新后的 `proto/contract/v1/contract.proto``src/go/proto/contract/v1/*.pb.go`
3. 通知各语言使用方按上面的命令重新生成

View File

@@ -0,0 +1,181 @@
syntax = "proto3";
package contract.v1;
option go_package = "goaiapi/proto/contract/v1;contractv1";
// ─── 通用服务:每个插件都必须实现 ───
service PluginManifest {
rpc GetManifest(GetManifestRequest) returns (GetManifestResponse);
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
}
message GetManifestRequest {}
message GetManifestResponse {
string name = 1; // 插件唯一名,如 "echo-base"
string version = 2; // semver如 "1.0.0"
PluginTier tier = 3; // 声明的层级(内核会用 DAG 复算校验)
repeated Dependency deps = 4;
repeated Capability caps = 5; // 本插件提供的 gRPC service 名
}
enum PluginTier {
TIER_UNSPECIFIED = 0;
TIER_BASE = 1; // 无插件依赖
TIER_MID = 2; // 仅依赖 base
TIER_COMPLEX = 3; // 依赖至少一个非 base
}
message Dependency {
string name = 1; // 依赖的插件名
string version_constraint = 2; // semver 约束,如 ">=1.0.0 <2.0.0"
}
message Capability {
string service_name = 1; // 如 "EchoService"
}
message HealthCheckRequest {}
message HealthCheckResponse {
bool healthy = 1;
string message = 2;
}
// ─── 调用代理:由内核托管,被插件调用 ───
service InvocationBroker {
rpc Invoke(InvokeRequest) returns (InvokeResponse);
rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse);
}
message InvokeRequest {
string target_plugin = 1; // 目标插件名
string method = 2; // 目标方法全路径,如 "/contract.v1.EchoService/Echo"
bytes payload = 3; // 已序列化的请求proto wire 字节)
}
message InvokeResponse {
bytes result = 1; // 已序列化的响应
string error = 2;
}
message ListPluginsRequest {}
message ListPluginsResponse { repeated PluginInfo plugins = 1; }
message PluginInfo {
string name = 1;
string version = 2;
PluginTier tier = 3;
bool healthy = 4;
}
// ─── 样板服务 ───
// 基本插件
service EchoService { rpc Echo(EchoRequest) returns (EchoResponse); }
message EchoRequest { string message = 1; }
message EchoResponse { string message = 1; string echoed_by = 2; }
// 依赖基本(内部调 Echo
service TransformService { rpc Transform(TransformRequest) returns (TransformResponse); }
message TransformRequest { string text = 1; string operation = 2; } // upper|lower|reverse
message TransformResponse { string result = 1; string via_echo = 2; }
// 复杂(依赖 base+mid含 server-streaming验证 SSE-over-gRPC
service StreamingDemo {
rpc UnaryPing(PingRequest) returns (PingResponse);
rpc StreamEvents(StreamRequest) returns (stream StreamEvent);
}
message PingRequest { string message = 1; }
message PingResponse { string message = 1; string pipeline = 2; }
message StreamRequest { int32 count = 1; int32 interval_ms = 2; }
message StreamEvent { int32 sequence = 1; string payload = 2; int64 timestamp_ns = 3; }
// ─── Relay 业务服务(核心能力下沉为插件) ───
// 一条请求-响应留存记录的可序列化表示(跨插件传递用)。
message TrafficEntry {
int64 id = 1;
int64 timestamp_ms = 2;
string client_ip = 3;
string method = 4;
string path = 5;
map<string,string> req_header = 6;
bytes req_body = 7;
int32 status = 8;
map<string,string> resp_header = 9;
bytes resp_body = 10;
int64 duration_ms = 11;
string api_format = 12; // openai | claude | ollama | unknown
string model = 13;
int32 input_tokens = 14;
int32 output_tokens = 15;
int32 total_tokens = 16;
bool is_stream = 17;
string blob_path = 18;
}
// UsageInfo从响应体解析出的用量信息。
message UsageInfo {
string api_format = 1;
string model = 2;
int32 input_tokens = 3;
int32 output_tokens = 4;
int32 total_tokens = 5;
}
// ── RecorderServiceBASE留存 + 查询 ──
service RecorderService {
rpc Save(SaveRequest) returns (SaveResponse);
rpc List(ListRequest) returns (ListResponse);
rpc ReadBlob(ReadBlobRequest) returns (ReadBlobResponse);
rpc Stats(StatsRequest) returns (StatsResponse);
}
message SaveRequest { TrafficEntry entry = 1; }
message SaveResponse { int64 id = 1; string error = 2; }
message ListRequest { int32 limit = 1; int32 offset = 2; string model = 3; string format = 4; int32 status = 5; }
message ListResponse { repeated TrafficEntry records = 1; int32 total = 2; }
message ReadBlobRequest { int64 id = 1; }
message ReadBlobResponse { TrafficEntry entry = 1; string error = 2; }
message StatsRequest {}
message ModelStat { string model = 1; int32 count = 2; int64 input_tokens = 3; int64 output_tokens = 4; int64 total_tokens = 5; }
message StatsResponse {
int32 total_requests = 1;
int32 success_count = 2;
int32 error_count = 3;
int64 total_tokens = 4;
repeated ModelStat by_model = 5;
}
// ── UsageServiceBASE用量解析非流式整体 / 流式全文) ──
service UsageService {
rpc ParseBody(ParseBodyRequest) returns (UsageInfo);
rpc ParseStream(ParseStreamRequest) returns (UsageInfo);
}
message ParseBodyRequest { bytes body = 1; string path = 2; }
message ParseStreamRequest { bytes raw = 1; string path = 2; } // raw = 拼回的全量 SSE 原文
// ── ProxyServiceCOMPLEX内核把外部请求转发到此由它转上游并触发留存 ──
service ProxyService {
rpc Handle(HandleRequest) returns (HandleResponse);
}
message HandleRequest {
string method = 1;
string path = 2;
string query = 3;
map<string,string> req_header = 4;
bytes req_body = 5;
string client_ip = 6;
}
message HandleResponse {
int32 status = 1;
map<string,string> resp_header = 2;
bytes resp_body = 3;
string error = 4;
}
// ── DashboardServiceMID依赖 recorder只读管理界面 ──
service DashboardService {
rpc Render(RenderRequest) returns (RenderResponse);
}
message RenderRequest { string path = 1; string query = 2; }
message RenderResponse {
int32 status = 1;
string content_type = 2;
bytes body = 3;
}

27
scripts/README.md Normal file
View File

@@ -0,0 +1,27 @@
# scripts — 构建与代码生成脚本
PowerShell 脚本,需在 Windows PowerShell 5.1 下运行。脚本内对 Go 工具链与 protoc 的路径做了硬编码,换机时按本机实际调整。
| 脚本 | 作用 |
|---|---|
| `build.ps1` | 全量构建:① 调 `genproto.ps1` 生成 proto 代码 → ② 编译内核入口 `cmd/relay``build/relay.exe` → ③ 逐个编译全部插件到 `build/plugins/<name>.exe` |
| `genproto.ps1` | 从 `src/go/proto/contract/v1/contract.proto` 生成 Go 代码(`*.pb.go` / `*_grpc.pb.go`,已入库)。仅在改契约时需要重跑 |
## 用法
```powershell
.\scripts\build.ps1 # 完整构建(含 codegen
.\scripts\genproto.ps1 # 仅重新生成 proto 代码
```
## 产物
`build.ps1` 产出:
- `build/relay.exe` — 内核入口
- `build/plugins/{echo-base,transform-mid,streaming-demo,recorder-base,usage-base,proxy,dashboard}.exe` — 七个插件(产物名与各自 manifest 中的插件名一致)
## 注意
- 脚本注释一律用 ASCII。PowerShell 5.1 对含中文注释的脚本可能因 GBK/UTF-8 解码不一致而误判 token导致解析错误`build.ps1` 全用英文注释。
- `protoc-gen-go` / `protoc-gen-go-grpc` 须先 `go install``GOPATH\bin`,脚本会把该目录加到 PATH 供 protoc 发现。

View File

@@ -1,12 +1,54 @@
# 编译脚本(位于 scripts/):源码在 src/go/,产物输出到 build/relay.exe
# Build script (in scripts/):
# 1) generate proto code (calls genproto.ps1)
# 2) build kernel entry cmd/relay -> build/relay.exe
# 3) build all plugins -> build/plugins/<name>.exe
# Source in src/go/, artifacts output to build/.
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent # 仓库根目录(scripts/ 的上一级)
$out = Join-Path $root 'build\relay.exe'
$root = Split-Path $PSScriptRoot -Parent # repo root (parent of scripts/)
Push-Location (Join-Path $root 'src\go')
$goExe = 'D:\RJ\Basic\GO\bin\go.exe'
$gopath = 'C:\Users\Administrator\go'
$env:GOPATH = $gopath
$env:PATH = "$gopath\bin;$env:PATH"
$srcGo = Join-Path $root 'src\go'
$relayOut = Join-Path $root 'build\relay.exe'
$pluginOut = Join-Path $root 'build\plugins'
# 1) generate proto code
& (Join-Path $PSScriptRoot 'genproto.ps1')
# 2) build kernel entry
Push-Location $srcGo
try {
go build -o $out .
Write-Host "Built: $out"
& $goExe build -o $relayOut ./cmd/relay
if ($LASTEXITCODE -ne 0) { throw "build relay failed (exit $LASTEXITCODE)" }
Write-Host "Built: $relayOut"
} finally {
Pop-Location
}
# 3) build all plugins (output name matches manifest plugin name)
New-Item -ItemType Directory -Force $pluginOut | Out-Null
$plugins = @(
@{ dir = 'echo'; out = 'echo-base.exe' },
@{ dir = 'transform'; out = 'transform-mid.exe' },
@{ dir = 'streaming'; out = 'streaming-demo.exe' },
@{ dir = 'recorder'; out = 'recorder-base.exe' },
@{ dir = 'usage'; out = 'usage-base.exe' },
@{ dir = 'proxy'; out = 'proxy.exe' },
@{ dir = 'dashboard'; out = 'dashboard.exe' }
)
Push-Location $srcGo
try {
foreach ($p in $plugins) {
$target = Join-Path $pluginOut $p.out
& $goExe build -o $target ('./plugins/' + $p.dir)
if ($LASTEXITCODE -ne 0) { throw ("build plugin " + $p.dir + " failed (exit $LASTEXITCODE)") }
Write-Host "Built plugin: $target"
}
} finally {
Pop-Location
}
Write-Host "All artifacts built under build/."

26
scripts/genproto.ps1 Normal file
View File

@@ -0,0 +1,26 @@
# Proto codegen script.
# Canonical .proto lives at repo-root proto/ (language-neutral contract).
# Generated Go code (*.pb.go / *_grpc.pb.go) is written into src/go/proto/ and
# committed to git, so cloning + `go build` works without protoc.
# Only re-run this when the .proto contract changes.
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent # repo root
$protoc = 'D:\RJ\Tool\Anaconda3\Library\bin\protoc.exe'
$gopath = 'C:\Users\Administrator\go'
# protoc needs protoc-gen-go / protoc-gen-go-grpc (in GOPATH\bin) on PATH
$env:GOPATH = $gopath
$env:PATH = "$gopath\bin;$env:PATH"
$protoRoot = Join-Path $root 'proto' # proto import root
$goOut = Join-Path $root 'src\go\proto' # Go output root (module-relative proto/)
New-Item -ItemType Directory -Force $goOut | Out-Null
& $protoc "--proto_path=$protoRoot" `
"--go_out=$goOut" --go_opt=paths=source_relative `
"--go-grpc_out=$goOut" --go-grpc_opt=paths=source_relative `
contract/v1/contract.proto
if ($LASTEXITCODE -ne 0) { throw "protoc failed (exit $LASTEXITCODE)" }
Write-Host "Proto generated: src/go/proto/contract/v1/*.pb.go (from proto/contract/v1/contract.proto)"

9
src/README.md Normal file
View File

@@ -0,0 +1,9 @@
# src — 源码
项目的全部源码。目前只有一个 Go 模块,位于 `go/`
| 子目录 | 说明 |
|---|---|
| `go/` | Go 模块 `goaiapi`:内核、插件 SDK、契约、全部插件源码 |
模块布局、构建方式与各包职责见 `go/README.md`

50
src/go/README.md Normal file
View File

@@ -0,0 +1,50 @@
# src/go — Go 模块 `goaiapi`
插件化中继的全部 Go 源码。模块采用 `cmd` / `internal` / `plugins` 分层布局:内核是纯加载器,中继的全部业务能力都下沉为独立进程插件。
## 布局
| 路径 | 说明 |
|---|---|
| `cmd/relay/` | 内核入口(`package main`,唯一可执行)。纯加载器:启动 Broker、扫描加载插件、起控制端口、监听 `:8080` 把流量经 Broker 转给 `proxy` 插件、优雅关闭。无业务逻辑 |
| `internal/kernel/` | 内核基础设施包,不对外导出。注册表 / 加载器 / 依赖解析 / Broker / 控制面 |
| `pluginsdk/` | 插件作者 import 的公共 SDK屏蔽 go-plugin 握手细节,提供 `Serve` 入口、`KernelClient`经内核互调、manifest 类型与 marshal 辅助 |
| `proto/contract/v1/` | 由 `contract.proto` 生成的 Go 代码(`*.pb.go` / `*_grpc.pb.go`,已入库)。**契约源在仓库根 `proto/`**,见 `proto/README.md` |
| `plugins/` | 七个插件,各为独立 `package main`,分别编译成二进制 |
## internal/kernel 各文件
| 文件 | 职责 |
|---|---|
| `types.go` | `PluginInfo`(含 `*grpc.ClientConn`、go-plugin 生命周期句柄)、`Registry``RWMutex` + 名字表 + 依赖边) |
| `launcher.go` | 经 go-plugin 启动插件进程,注入 `GOAIAPI_KERNEL_ADDR`,取裸 conn`GetManifest` |
| `registry.go` | `Load` / `Unload` / `Get` / `List` / `Shutdown` / `Upgrade`(热升级:原子换 + 下游重校验 + 旧进程延迟排空) |
| `resolver.go` | `resolveManifest`semver 约束校验 + tier DAG 复算(比对自报)+ 白/灰/黑 DFS 环检测 |
| `broker.go` | `InvocationBroker` 服务端 + bytes 恒等编解码器(透传 proto-wire 字节,内核不感知插件类型) |
| `control.go` | 独立控制端口 HTTP 服务load/unload/upgrade/list/detail |
## plugins 各插件
| 目录 | 插件名 | 层级 | 依赖 | 实现服务 |
|---|---|---|---|---|
| `echo/` | echo-base | base | — | `EchoService`(样例) |
| `recorder/` | recorder-base | base | — | `RecorderService`SQLite+blob 留存查询) |
| `usage/` | usage-base | base | — | `UsageService`(非流式/流式用量解析) |
| `transform/` | transform-mid | mid | echo-base | `TransformService`(样例,经 Broker 调 echo |
| `dashboard/` | dashboard | mid | recorder-base | `DashboardService`(只读管理界面) |
| `proxy/` | proxy | complex | usage-base, recorder-base | `ProxyService`(转上游 + 触发解析/留存) |
| `streaming/` | streaming-demo | complex | echo-base, transform-mid | `StreamingDemo`(样例,含 server-streaming |
## 构建
从仓库根用 `scripts/build.ps1` 一键构建(含 codegen。单独操作
```powershell
# 仅本模块(需 Go 工具链在 PATH 上,否则用脚本里的硬编码路径)
go build ./... # 编译全部包
go test ./internal/kernel # 内核单元/集成测试(会现编译样例插件)
```
## 互调模型
插件从不直连。`pluginsdk.KernelClient.Invoke(target, method, payload)` → 内核 `InvocationBroker` → 目标插件。`method` 是 gRPC 全路径(如 `/contract.v1.EchoService/Echo``payload` 是用 `proto/` 契约生成的类型序列化的字节。详见根 `proto/README.md`

227
src/go/cmd/relay/main.go Normal file
View File

@@ -0,0 +1,227 @@
// 内核入口(纯加载器):只负责启动 broker、加载插件、起控制端口、监听 8080
// 把外部流量经 broker 转发给 proxy 插件,以及优雅关闭。不含任何业务逻辑——
// relay 的全部能力转发、留存、解析、dashboard都由插件实现。
package main
import (
"context"
"flag"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"goaiapi/internal/kernel"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
)
var (
listenAddr = flag.String("l", "127.0.0.1:8080", "外部流量监听地址 host:port")
pluginDir = flag.String("pd", "build/plugins", "插件目录(扫描 *.exe 自动加载;空字符串则不自动加载)")
brokerAddr = flag.String("pa", "127.0.0.1:0", "内核 broker gRPC 地址(:0 = 随机端口)")
controlAddr = flag.String("ca", "127.0.0.1:8082", "插件管理控制端口 host:port空字符串关闭")
proxyPlugin = flag.String("proxy", "proxy", "处理外部流量的代理插件名")
drainMs = flag.Int("drain", 5000, "热升级时旧插件进程排空等待毫秒数")
)
// proxyMethod 是外部流量转发到代理插件时调用的方法全路径。
const proxyMethod = "/contract.v1.ProxyService/Handle"
func main() {
flag.Parse()
reg := kernel.NewRegistry()
// 1) 启动 InvocationBroker gRPC 服务,取实际地址供插件连回。
brokerSrv, brokerLis, err := kernel.StartBrokerServer(*brokerAddr, reg)
if err != nil {
log.Fatalf("启动 broker 失败: %v", err)
}
actualBroker := brokerLis.Addr().String()
log.Printf("InvocationBroker 启动: %s", actualBroker)
// 2) 扫描插件目录自动加载。失败仅记日志,不致命。
if *pluginDir != "" {
loadPluginsFromDir(reg, *pluginDir, actualBroker)
}
// 3) 起独立控制端口(动态加载/卸载/升级)。
if *controlAddr != "" {
ctrl := kernel.NewController(reg, actualBroker, time.Duration(*drainMs)*time.Millisecond)
go func() {
log.Printf("插件控制端口启动: http://%s", *controlAddr)
if err := ctrl.StartControlServer(*controlAddr); err != nil {
log.Printf("[!] 控制端口退出: %v", err)
}
}()
}
// 4) 8080 流量监听:每个请求经 broker 转给代理插件处理。
srv := &http.Server{
Addr: *listenAddr,
Handler: http.HandlerFunc(makeTrafficHandler(actualBroker, *proxyPlugin)),
}
go func() {
log.Printf("流量监听启动: %s -> 插件 %q", *listenAddr, *proxyPlugin)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("流量监听退出: %v", err)
}
}()
// 5) 等待信号,优雅关闭。
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
log.Printf("收到关闭信号,正在停止…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
reg.Shutdown()
brokerSrv.GracefulStop()
log.Printf("已退出。")
}
// loadPluginsFromDir 扫描目录下的 *.exe 逐个加载。为满足依赖顺序base 须先于
// 依赖它的插件),失败的插件会重试若干轮,直到一轮内无新增成功为止。
func loadPluginsFromDir(reg *kernel.Registry, dir, brokerAddr string) {
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("[!] 读插件目录 %s 失败: %v", dir, err)
return
}
var pending []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(strings.ToLower(name), ".exe") {
pending = append(pending, filepath.Join(dir, name))
}
}
// 多轮加载:每轮尝试所有 pending依赖未满足的留到下一轮。
ctx := context.Background()
for round := 0; len(pending) > 0; round++ {
var stillPending []string
progressed := false
for _, path := range pending {
info, err := reg.Load(ctx, path, brokerAddr)
if err != nil {
stillPending = append(stillPending, path)
continue
}
progressed = true
log.Printf("[+] 已加载插件 %s v%s tier=%v caps=%v",
info.Name, info.Version, info.Tier, info.Capabilities)
}
pending = stillPending
if !progressed {
break
}
}
for _, path := range pending {
// 最后一轮仍失败:再跑一次以拿到真实错误信息记日志。
if _, err := reg.Load(context.Background(), path, brokerAddr); err != nil {
log.Printf("[!] 加载插件 %s 失败: %v", path, err)
}
}
}
// makeTrafficHandler 返回一个 HTTP handler把每个请求打包成 HandleRequest
// 经 broker 转发给代理插件的 ProxyService/Handle再把插件返回的响应回写客户端。
func makeTrafficHandler(brokerAddr, proxyName string) http.HandlerFunc {
// 内核作为 broker 的本地客户端连回自己。
conn, err := grpc.NewClient(brokerAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("内核连 broker 失败: %v", err)
}
broker := contractv1.NewInvocationBrokerClient(conn)
return func(w http.ResponseWriter, r *http.Request) {
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
}
hreq := &contractv1.HandleRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
ReqHeader: flattenHeader(r.Header),
ReqBody: reqBody,
ClientIp: clientIP(r),
}
payload, err := proto.Marshal(hreq)
if err != nil {
http.Error(w, "marshal request: "+err.Error(), http.StatusInternalServerError)
return
}
resp, err := broker.Invoke(r.Context(), &contractv1.InvokeRequest{
TargetPlugin: proxyName,
Method: proxyMethod,
Payload: payload,
})
if err != nil {
http.Error(w, "broker invoke: "+err.Error(), http.StatusBadGateway)
return
}
if resp.Error != "" {
http.Error(w, "proxy plugin: "+resp.Error, http.StatusBadGateway)
return
}
var hresp contractv1.HandleResponse
if err := proto.Unmarshal(resp.Result, &hresp); err != nil {
http.Error(w, "unmarshal response: "+err.Error(), http.StatusInternalServerError)
return
}
if hresp.Error != "" {
http.Error(w, "proxy plugin: "+hresp.Error, http.StatusBadGateway)
return
}
for k, v := range hresp.RespHeader {
w.Header().Set(k, v)
}
status := int(hresp.Status)
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
_, _ = w.Write(hresp.RespBody)
}
}
// flattenHeader 把 http.Header 拍平成单层 map。
func flattenHeader(h http.Header) map[string]string {
m := make(map[string]string, len(h))
for k, v := range h {
m[k] = strings.Join(v, ", ")
}
return m
}
// clientIP 取客户端 IP去掉端口
func clientIP(r *http.Request) string {
ip := r.RemoteAddr
if i := strings.LastIndex(ip, ":"); i >= 0 {
ip = ip[:i]
}
return ip
}
// 引用 pluginsdk 以保证模块图完整(入口依赖 SDK 的握手常量已在 kernel 内部用到,
// 此处显式留一个轻量引用避免未来误删依赖)。
var _ = pluginsdk.KernelEnvAddr

View File

@@ -1,47 +0,0 @@
package main
import (
"context"
"time"
)
// Entry 一次完整的请求-响应留存记录。
type Entry struct {
ID int64
Timestamp time.Time
ClientIP string
Method string
Path string
ReqHeader map[string]string
ReqBody []byte
Status int
RespHeader map[string]string
RespBody []byte
DurationMs int64
// 解析出的用量信息
APIFormat string // openai | claude | ollama | unknown
Model string
InputTokens int
OutputTokens int
TotalTokens int
IsStream bool
// 原文落盘后的相对路径
BlobPath string
}
// entryCtxKey 用于在 request context 里传递 Entry避免污染全局。
type entryCtxKey struct{}
func withEntry(ctx context.Context, e *Entry) context.Context {
return context.WithValue(ctx, entryCtxKey{}, e)
}
func entryFrom(ctx context.Context) *Entry {
e, _ := ctx.Value(entryCtxKey{}).(*Entry)
return e
}

View File

@@ -2,15 +2,30 @@ module goaiapi
go 1.26.3
require modernc.org/sqlite v1.51.0
require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/hashicorp/go-plugin v1.8.0
google.golang.org/grpc v1.81.1
modernc.org/sqlite v1.51.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@@ -1,26 +1,97 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=

View File

@@ -0,0 +1,91 @@
package kernel
import (
"context"
"fmt"
"net"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
)
// bytesCodec 是一个恒等编解码器:把 []byte 原样当作 wire 字节,不做任何
// proto 序列化。Broker 用它经 conn.Invoke 转发已序列化的 payload从而内核
// 无需 import 任何插件的 proto 类型——对插件服务完全无关。
type bytesCodec struct{}
func (bytesCodec) Marshal(v interface{}) ([]byte, error) {
b, ok := v.([]byte)
if !ok {
return nil, fmt.Errorf("bytesCodec: 期望 []byte得到 %T", v)
}
return b, nil
}
func (bytesCodec) Unmarshal(data []byte, v interface{}) error {
p, ok := v.(*[]byte)
if !ok {
return fmt.Errorf("bytesCodec: 期望 *[]byte得到 %T", v)
}
*p = append([]byte(nil), data...)
return nil
}
func (bytesCodec) Name() string { return "goaiapi.bytes" }
func init() {
encoding.RegisterCodec(bytesCodec{})
}
// BrokerServer 实现 InvocationBroker把 Invoke 路由到目标插件的 gRPC 连接,
// 原样转发 proto-wire 字节。
type BrokerServer struct {
contractv1.UnimplementedInvocationBrokerServer
reg *Registry
}
// NewBrokerServer 构造一个绑定到 reg 的 broker。
func NewBrokerServer(reg *Registry) *BrokerServer {
return &BrokerServer{reg: reg}
}
// Invoke 把 (target, method, payload) 转发到目标插件的连接,返回响应字节。
// 失败时把错误写进 InvokeResponse.Error 而非作为 gRPC error 返回,便于调用
// 方区分「broker 层失败」与「目标方法返回的业务错误」。
func (b *BrokerServer) Invoke(ctx context.Context, req *contractv1.InvokeRequest) (*contractv1.InvokeResponse, error) {
info := b.reg.Get(req.TargetPlugin)
if info == nil {
return &contractv1.InvokeResponse{Error: "目标插件未找到: " + req.TargetPlugin}, nil
}
var respData []byte
err := info.Conn.Invoke(ctx, req.Method, req.Payload, &respData, grpc.ForceCodec(bytesCodec{}))
if err != nil {
return &contractv1.InvokeResponse{Error: err.Error()}, nil
}
return &contractv1.InvokeResponse{Result: respData}, nil
}
// ListPlugins 返回所有已注册插件的摘要。
func (b *BrokerServer) ListPlugins(ctx context.Context, _ *contractv1.ListPluginsRequest) (*contractv1.ListPluginsResponse, error) {
infos := b.reg.List()
out := &contractv1.ListPluginsResponse{Plugins: make([]*contractv1.PluginInfo, 0, len(infos))}
for _, info := range infos {
out.Plugins = append(out.Plugins, info.toProtoInfo())
}
return out, nil
}
// StartBrokerServer 在 addr 上起一个 gRPC server 托管 InvocationBroker。
// addr 用 "127.0.0.1:0" 可让系统分配端口;返回的 listener 的 Addr() 即实际
// 地址,供 main 注入给被加载的插件。
func StartBrokerServer(addr string, reg *Registry) (*grpc.Server, net.Listener, error) {
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, nil, fmt.Errorf("broker 监听 %s: %w", addr, err)
}
srv := grpc.NewServer()
contractv1.RegisterInvocationBrokerServer(srv, NewBrokerServer(reg))
go srv.Serve(lis)
return srv, lis, nil
}

View File

@@ -0,0 +1,140 @@
package kernel
import (
"context"
"encoding/json"
"net/http"
"time"
)
// Controller 是动态加载/更新的控制面,绑定在独立内核控制端口(与只读
// dashboard 物理隔离)。它在请求路径上调 Registry 的 Load/Unload/Upgrade。
type Controller struct {
reg *Registry
brokerAddr string
drain time.Duration
}
// NewController 构造一个控制面brokerAddr 用于注入给新加载的插件drain 是
// 热升级时旧进程的排空等待时长。
func NewController(reg *Registry, brokerAddr string, drain time.Duration) *Controller {
return &Controller{reg: reg, brokerAddr: brokerAddr, drain: drain}
}
// Mux 组装控制面路由。单独抽出便于测试与挂载。
func (c *Controller) Mux() http.Handler {
mux := http.NewServeMux()
// 列出所有已加载插件
mux.HandleFunc("GET /api/plugins", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, c.listView())
})
// 单个插件详情
mux.HandleFunc("GET /api/plugins/{name}", func(w http.ResponseWriter, r *http.Request) {
info := c.reg.Get(r.PathValue("name"))
if info == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "插件未注册"})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
// 加载注册一个插件二进制
mux.HandleFunc("POST /api/plugins/load", func(w http.ResponseWriter, r *http.Request) {
var body struct {
BinaryPath string `json:"binary_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BinaryPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 binary_path"})
return
}
info, err := c.reg.Load(context.Background(), body.BinaryPath, c.brokerAddr)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
// 卸载一个插件
mux.HandleFunc("POST /api/plugins/unload", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 name"})
return
}
if err := c.reg.Unload(body.Name); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"unloaded": body.Name})
})
// 热升级一个已注册插件到新二进制
mux.HandleFunc("POST /api/plugins/upgrade", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
BinaryPath string `json:"binary_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" || body.BinaryPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "需要 name 与 binary_path"})
return
}
info, err := c.reg.Upgrade(context.Background(), body.Name, body.BinaryPath, c.brokerAddr, c.drain)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, pluginViewOf(info))
})
return mux
}
// StartControlServer 在 addr 上起控制面 HTTP 服务(阻塞,建议 go 调用)。
func (c *Controller) StartControlServer(addr string) error {
return http.ListenAndServe(addr, c.Mux())
}
// pluginView 是单个插件对外的 JSON 视图(不暴露 conn/client 等内部句柄)。
type pluginView struct {
Name string `json:"name"`
Version string `json:"version"`
Tier string `json:"tier"`
Dependencies []string `json:"dependencies"`
Capabilities []string `json:"capabilities"`
Healthy bool `json:"healthy"`
}
func pluginViewOf(info *PluginInfo) pluginView {
deps := make([]string, 0, len(info.Dependencies))
for _, d := range info.Dependencies {
deps = append(deps, d.Name+" "+d.VersionConstraint)
}
return pluginView{
Name: info.Name,
Version: info.Version,
Tier: info.Tier.String(),
Dependencies: deps,
Capabilities: info.Capabilities,
Healthy: info.Healthy,
}
}
func (c *Controller) listView() []pluginView {
infos := c.reg.List()
out := make([]pluginView, 0, len(infos))
for _, info := range infos {
out = append(out, pluginViewOf(info))
}
return out
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,102 @@
package kernel
import (
"context"
"testing"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/protobuf/proto"
)
// TestCrossPluginInvocation 起 broker依次加载 echo/transform/streaming验证三层
// 依赖与 tier 复算正确,并经 broker 调 transform-mid其内部又经 broker 调 echo-base
func TestCrossPluginInvocation(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
transformBin := pluginBin(t, "../../plugins/transform")
streamingBin := pluginBin(t, "../../plugins/streaming")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
ctx := context.Background()
// 1) echo-base (BASE)
echo, err := reg.Load(ctx, echoBin, brokerAddr)
if err != nil {
t.Fatalf("load echo: %v", err)
}
if echo.Tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("echo tier = %v, want BASE", echo.Tier)
}
// 2) transform-mid (MID, deps echo-base)
transform, err := reg.Load(ctx, transformBin, brokerAddr)
if err != nil {
t.Fatalf("load transform: %v", err)
}
if transform.Tier != contractv1.PluginTier_TIER_MID {
t.Errorf("transform tier = %v, want MID", transform.Tier)
}
// 3) streaming-demo (COMPLEX, deps echo-base + transform-mid)
streaming, err := reg.Load(ctx, streamingBin, brokerAddr)
if err != nil {
t.Fatalf("load streaming: %v", err)
}
if streaming.Tier != contractv1.PluginTier_TIER_COMPLEX {
t.Errorf("streaming tier = %v, want COMPLEX", streaming.Tier)
}
// 经 broker 调 transform-mid.Transform("hello","upper")。
// transform 内部又经 broker 调 echo-basevia_echo 应被填回 "echo-base"。
broker := NewBrokerServer(reg)
reqBytes, err := proto.Marshal(&contractv1.TransformRequest{Text: "hello", Operation: "upper"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
resp, err := broker.Invoke(ctx, &contractv1.InvokeRequest{
TargetPlugin: "transform-mid",
Method: "/contract.v1.TransformService/Transform",
Payload: reqBytes,
})
if err != nil {
t.Fatalf("broker.Invoke: %v", err)
}
if resp.Error != "" {
t.Fatalf("invoke error: %s", resp.Error)
}
var tResp contractv1.TransformResponse
if err := proto.Unmarshal(resp.Result, &tResp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tResp.Result != "HELLO" {
t.Errorf("result = %q, want HELLO", tResp.Result)
}
if tResp.ViaEcho != "echo-base" {
t.Errorf("via_echo = %q, want echo-base (transform 应经 broker 调到 echo)", tResp.ViaEcho)
}
}
// TestListPluginsViaBroker 验证 broker.ListPlugins 返回所有已加载插件摘要。
func TestListPluginsViaBroker(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load: %v", err)
}
broker := NewBrokerServer(reg)
resp, err := broker.ListPlugins(context.Background(), &contractv1.ListPluginsRequest{})
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(resp.Plugins) != 1 || resp.Plugins[0].Name != "echo-base" {
t.Errorf("plugins = %+v, want one echo-base", resp.Plugins)
}
}
var _ = pluginsdk.KernelEnvAddr

View File

@@ -0,0 +1,73 @@
package kernel
import (
"context"
"os"
"os/exec"
plugin "github.com/hashicorp/go-plugin"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
// grpcConnPlugin 是内核侧的 go-plugin 适配器GRPCClient 直接返回裸
// *grpc.ClientConn供 Broker 在其上调用任意 service与 pluginsdk 的
// grpcPlugin 对称)。
type grpcConnPlugin struct {
plugin.Plugin
}
func (grpcConnPlugin) GRPCServer(*plugin.GRPCBroker, *grpc.Server) error { return nil }
func (grpcConnPlugin) GRPCClient(_ context.Context, _ *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return c, nil
}
// launchResult 是一次成功启动的产物。
type launchResult struct {
client *plugin.Client
conn *grpc.ClientConn
manifest *contractv1.GetManifestResponse
}
// launch 启动一个插件二进制进程,完成握手,取裸 conn 并调 GetManifest。
// brokerAddr 经环境变量注入,插件用 pluginsdk.NewKernelClient 读它连回内核。
func launch(ctx context.Context, binaryPath, brokerAddr string) (*launchResult, error) {
cmd := exec.Command(binaryPath)
cmd.Env = append(os.Environ(), pluginsdk.KernelEnvAddr+"="+brokerAddr)
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: plugin.HandshakeConfig{
ProtocolVersion: pluginsdk.Handshake.ProtocolVersion,
MagicCookieKey: pluginsdk.Handshake.MagicCookieKey,
MagicCookieValue: pluginsdk.Handshake.MagicCookieValue,
},
Plugins: plugin.PluginSet{
"plugin": grpcConnPlugin{},
},
Cmd: cmd,
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
})
rpcClient, err := client.Client()
if err != nil {
client.Kill()
return nil, err
}
raw, err := rpcClient.Dispense("plugin")
if err != nil {
client.Kill()
return nil, err
}
conn := raw.(*grpc.ClientConn)
mc := contractv1.NewPluginManifestClient(conn)
manifest, err := mc.GetManifest(ctx, &contractv1.GetManifestRequest{})
if err != nil {
client.Kill()
return nil, err
}
return &launchResult{client: client, conn: conn, manifest: manifest}, nil
}

View File

@@ -0,0 +1,65 @@
package kernel
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// goExe 是测试用 Go 工具链路径(不在 PATH 上)。
const goExe = "D:\\RJ\\Basic\\GO\\bin\\go.exe"
// prebuilt 是 TestMain 一次性编译好的样例插件二进制路径,按插件包名索引。
// 各测试共享,避免每个测试函数重复 go build首次 exec 触发 Defender 扫描,
// 重复编译会让整套测试超时)。
var prebuilt = map[string]string{}
// TestMain 在跑任何测试前,把样例插件各编译一次到一个共享临时目录。
func TestMain(m *testing.M) {
tmp, err := os.MkdirTemp("", "kernel-test-plugins")
if err != nil {
panic("创建插件临时目录: " + err.Error())
}
// 插件包相对路径(相对 internal/kernel -> 产物文件名。
specs := map[string]string{
"../../plugins/echo": "echo-base.exe",
"../../plugins/transform": "transform-mid.exe",
"../../plugins/streaming": "streaming-demo.exe",
}
for pkg, outName := range specs {
out := filepath.Join(tmp, outName)
cmd := exec.Command(goExe, "build", "-o", out, pkg)
if combined, err := cmd.CombinedOutput(); err != nil {
os.RemoveAll(tmp)
panic("编译测试插件 " + pkg + " 失败: " + err.Error() + "\n" + string(combined))
}
prebuilt[pkg] = out
}
code := m.Run()
os.RemoveAll(tmp)
os.Exit(code)
}
// pluginBin 返回 TestMain 预编译好的插件二进制路径。
func pluginBin(t *testing.T, pkgRelPath string) string {
t.Helper()
bin, ok := prebuilt[pkgRelPath]
if !ok {
t.Fatalf("插件 %s 未在 TestMain 预编译", pkgRelPath)
}
return bin
}
// startTestBroker 在随机端口起一个 broker 并返回其地址,测试结束自动停。
func startTestBroker(t *testing.T, reg *Registry) string {
t.Helper()
srv, lis, err := StartBrokerServer("127.0.0.1:0", reg)
if err != nil {
t.Fatalf("启动 broker: %v", err)
}
t.Cleanup(func() { srv.Stop() })
return lis.Addr().String()
}

View File

@@ -0,0 +1,279 @@
package kernel
import (
"context"
"fmt"
"time"
"github.com/Masterminds/semver/v3"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
)
// Load 启动并注册一个插件二进制launch 起进程拿 conn+manifest →
// ValidateManifest → resolveManifest依赖/版本/tier/环)→ 通过则写锁插入
// byName 与 edges。任一步失败立刻 Kill绝不留下游离进程或半状态。
func (reg *Registry) Load(ctx context.Context, binaryPath, brokerAddr string) (*PluginInfo, error) {
lr, err := launch(ctx, binaryPath, brokerAddr)
if err != nil {
return nil, fmt.Errorf("启动插件 %s: %w", binaryPath, err)
}
m := lr.manifest
// 校验 manifest 自洽(名字/版本 semver/依赖约束可解析)。
sdkManifest := pluginsdk.Manifest{
Name: m.Name,
Version: m.Version,
Tier: m.Tier,
}
for _, d := range m.Deps {
sdkManifest.Dependencies = append(sdkManifest.Dependencies, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
if err := pluginsdk.ValidateManifest(sdkManifest); err != nil {
lr.client.Kill()
return nil, err
}
sv, err := semver.NewVersion(m.Version)
if err != nil {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 版本 %q 非法: %w", m.Name, m.Version, err)
}
reg.mu.Lock()
defer reg.mu.Unlock()
if _, exists := reg.byName[m.Name]; exists {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 已注册(如需换版本请走 upgrade", m.Name)
}
computed, err := resolveManifest(reg, m)
if err != nil {
lr.client.Kill()
return nil, err
}
caps := make([]string, 0, len(m.Caps))
for _, c := range m.Caps {
caps = append(caps, c.ServiceName)
}
info := &PluginInfo{
Name: m.Name,
Version: m.Version,
SemVer: sv,
Tier: computed,
Dependencies: m.Deps,
Capabilities: caps,
Healthy: true,
BinaryPath: binaryPath,
Conn: lr.conn,
Client: lr.client,
}
reg.byName[m.Name] = info
reg.edges[m.Name] = depNames(m.Deps)
return info, nil
}
// Unload 卸载一个插件:先反向边检查(无其它插件依赖它)→ 移除 → Kill。
func (reg *Registry) Unload(name string) error {
reg.mu.Lock()
defer reg.mu.Unlock()
info, ok := reg.byName[name]
if !ok {
return fmt.Errorf("插件 %s 未注册", name)
}
// 反向边检查:是否有其它插件依赖 name。
for other, deps := range reg.edges {
if other == name {
continue
}
for _, d := range deps {
if d == name {
return fmt.Errorf("插件 %s 被 %s 依赖,无法卸载", name, other)
}
}
}
delete(reg.byName, name)
delete(reg.edges, name)
info.Client.Kill()
return nil
}
// Upgrade 热升级一个已注册插件到新二进制:先把新二进制作为临时进程加载并
// 校验 manifest/依赖/tier/环(基于「移除旧记录后」的注册表视图,避免与自身旧
// 版本冲突),通过则写锁原子换 byName/edges 指向新 PluginInfo旧进程在锁外
// 延迟 drain 后 Kill。任一步失败都 Kill 新进程并保持旧版本不变。
//
// 注意:新版本若 tier/依赖发生变化,会重校验所有依赖它的插件,任一不满足即拒绝。
func (reg *Registry) Upgrade(ctx context.Context, name, binaryPath, brokerAddr string, drain time.Duration) (*PluginInfo, error) {
reg.mu.RLock()
old, ok := reg.byName[name]
reg.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("插件 %s 未注册,无法升级", name)
}
// 起新进程拿 conn+manifest。
lr, err := launch(ctx, binaryPath, brokerAddr)
if err != nil {
return nil, fmt.Errorf("启动新版插件 %s: %w", binaryPath, err)
}
m := lr.manifest
if m.Name != name {
lr.client.Kill()
return nil, fmt.Errorf("新二进制插件名 %q 与待升级 %q 不符", m.Name, name)
}
sdkManifest := pluginsdk.Manifest{Name: m.Name, Version: m.Version, Tier: m.Tier}
for _, d := range m.Deps {
sdkManifest.Dependencies = append(sdkManifest.Dependencies, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
if err := pluginsdk.ValidateManifest(sdkManifest); err != nil {
lr.client.Kill()
return nil, err
}
sv, err := semver.NewVersion(m.Version)
if err != nil {
lr.client.Kill()
return nil, fmt.Errorf("插件 %s 版本 %q 非法: %w", m.Name, m.Version, err)
}
reg.mu.Lock()
// 基于「移除旧记录」的视图校验新版本,避免与自身旧版本误判成环/冲突。
delete(reg.byName, name)
delete(reg.edges, name)
computed, err := resolveManifest(reg, m)
if err != nil {
// 回滚:恢复旧记录。
reg.byName[name] = old
reg.edges[name] = depNames(old.Dependencies)
reg.mu.Unlock()
lr.client.Kill()
return nil, err
}
// 若 tier/依赖变化,重校验所有依赖它的插件(它们的约束仍须满足)。
caps := make([]string, 0, len(m.Caps))
for _, c := range m.Caps {
caps = append(caps, c.ServiceName)
}
info := &PluginInfo{
Name: m.Name,
Version: m.Version,
SemVer: sv,
Tier: computed,
Dependencies: m.Deps,
Capabilities: caps,
Healthy: true,
BinaryPath: binaryPath,
Conn: lr.conn,
Client: lr.client,
}
reg.byName[name] = info
reg.edges[name] = depNames(m.Deps)
// 下游重校验:任何依赖 name 的插件,其约束须被新版本满足。
if err := reg.revalidateDependents(name); err != nil {
// 回滚到旧版本。
reg.byName[name] = old
reg.edges[name] = depNames(old.Dependencies)
reg.mu.Unlock()
lr.client.Kill()
return nil, err
}
reg.mu.Unlock()
// 旧进程在锁外延迟 drain 后 Kill给在途请求收尾时间。
go func() {
if drain > 0 {
time.Sleep(drain)
}
old.Client.Kill()
}()
return info, nil
}
// revalidateDependents 重校验所有依赖 name 的插件:它们的版本约束须被
// 当前 reg.byName[name] 的版本满足。调用方须持有写锁。
func (reg *Registry) revalidateDependents(name string) error {
target := reg.byName[name]
if target == nil {
return nil
}
for other, info := range reg.byName {
if other == name {
continue
}
for _, d := range info.Dependencies {
if d.Name != name {
continue
}
c, err := semver.NewConstraint(d.VersionConstraint)
if err != nil {
return fmt.Errorf("插件 %s 依赖 %s 的约束 %q 非法: %w", other, name, d.VersionConstraint, err)
}
if !c.Check(target.SemVer) {
return fmt.Errorf("升级 %s 到 %s 会破坏 %s 的约束 %q",
name, target.Version, other, d.VersionConstraint)
}
}
}
return nil
}
// Get 返回指定插件的记录(未注册返回 nil
func (reg *Registry) Get(name string) *PluginInfo {
reg.mu.RLock()
defer reg.mu.RUnlock()
return reg.byName[name]
}
// List 返回所有已注册插件的快照。
func (reg *Registry) List() []*PluginInfo {
reg.mu.RLock()
defer reg.mu.RUnlock()
out := make([]*PluginInfo, 0, len(reg.byName))
for _, info := range reg.byName {
out = append(out, info)
}
return out
}
// Shutdown 关闭所有插件进程。
func (reg *Registry) Shutdown() {
reg.mu.Lock()
defer reg.mu.Unlock()
for name, info := range reg.byName {
info.Client.Kill()
delete(reg.byName, name)
delete(reg.edges, name)
}
}
// pluginTierInfo 是 ListPlugins 返回用的摘要构造辅助。
func (info *PluginInfo) toProtoInfo() *contractv1.PluginInfo {
return &contractv1.PluginInfo{
Name: info.Name,
Version: info.Version,
Tier: info.Tier,
Healthy: info.Healthy,
}
}

View File

@@ -0,0 +1,92 @@
package kernel
import (
"context"
"sync"
"testing"
contractv1 "goaiapi/proto/contract/v1"
)
func TestLoadBasePlugin(t *testing.T) {
bin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
info, err := reg.Load(context.Background(), bin, brokerAddr)
if err != nil {
t.Fatalf("Load: %v", err)
}
if info.Name != "echo-base" {
t.Errorf("name = %q, want echo-base", info.Name)
}
if info.Tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("tier = %v, want BASE", info.Tier)
}
if !info.Healthy {
t.Error("plugin not healthy")
}
}
func TestLoadDuplicateRejected(t *testing.T) {
bin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), bin, brokerAddr); err != nil {
t.Fatalf("first Load: %v", err)
}
if _, err := reg.Load(context.Background(), bin, brokerAddr); err == nil {
t.Fatal("期望重复加载报错,得到 nil")
}
}
func TestUnloadBlockedByDependent(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
transformBin := pluginBin(t, "../../plugins/transform")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load echo: %v", err)
}
if _, err := reg.Load(context.Background(), transformBin, brokerAddr); err != nil {
t.Fatalf("load transform: %v", err)
}
// echo-base 被 transform-mid 依赖,不能卸载。
if err := reg.Unload("echo-base"); err == nil {
t.Fatal("期望卸载被依赖插件报错,得到 nil")
}
// 先卸 transform-mid再卸 echo-base 应成功。
if err := reg.Unload("transform-mid"); err != nil {
t.Fatalf("unload transform: %v", err)
}
if err := reg.Unload("echo-base"); err != nil {
t.Fatalf("unload echo: %v", err)
}
}
func TestConcurrentLoadList(t *testing.T) {
echoBin := pluginBin(t, "../../plugins/echo")
reg := NewRegistry()
brokerAddr := startTestBroker(t, reg)
t.Cleanup(reg.Shutdown)
if _, err := reg.Load(context.Background(), echoBin, brokerAddr); err != nil {
t.Fatalf("load: %v", err)
}
// 并发读 List/Get配合 -race 检测数据竞争。
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = reg.List()
_ = reg.Get("echo-base")
}()
}
wg.Wait()
}

View File

@@ -0,0 +1,103 @@
package kernel
import (
"fmt"
"github.com/Masterminds/semver/v3"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
)
// resolveManifest 校验候选 manifest 对当前注册表是否自洽:
// 1. 逐依赖:目标已注册、版本约束可解析、已注册版本满足约束。
// 2. tier 复算:由注册表 + 候选依赖构建 allTiers调 pluginsdk.ComputeTier
// 与自报 tier 比对。
// 3. 环检测:把候选依赖临时并入 edgesDFS 着色发现回边即拒绝。
// 调用方必须持有 reg 的读锁(或在写锁内调用)。返回复算出的 tier。
func resolveManifest(reg *Registry, m *contractv1.GetManifestResponse) (contractv1.PluginTier, error) {
// 1) 依赖 + 版本约束
for _, d := range m.Deps {
dep, ok := reg.byName[d.Name]
if !ok {
return 0, fmt.Errorf("插件 %s 依赖 %s 尚未注册", m.Name, d.Name)
}
c, err := semver.NewConstraint(d.VersionConstraint)
if err != nil {
return 0, fmt.Errorf("插件 %s 依赖 %s 的约束 %q 非法: %w", m.Name, d.Name, d.VersionConstraint, err)
}
if dep.SemVer == nil || !c.Check(dep.SemVer) {
return 0, fmt.Errorf("插件 %s 依赖 %s 版本 %s 不满足约束 %q",
m.Name, d.Name, dep.Version, d.VersionConstraint)
}
}
// 2) tier 复算
allTiers := make(map[string]contractv1.PluginTier, len(reg.byName))
for name, info := range reg.byName {
allTiers[name] = info.Tier
}
sdkDeps := make([]pluginsdk.Dependency, 0, len(m.Deps))
for _, d := range m.Deps {
sdkDeps = append(sdkDeps, pluginsdk.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
computed := pluginsdk.ComputeTier(sdkDeps, allTiers)
if m.Tier != contractv1.PluginTier_TIER_UNSPECIFIED && m.Tier != computed {
return 0, fmt.Errorf("插件 %s 自报 tier=%v 与复算 tier=%v 不符", m.Name, m.Tier, computed)
}
// 3) 环检测:在 edges 副本上加入候选边后跑 DFS。
if err := checkNoCycle(reg.edges, m.Name, depNames(m.Deps)); err != nil {
return 0, err
}
return computed, nil
}
func depNames(deps []*contractv1.Dependency) []string {
out := make([]string, 0, len(deps))
for _, d := range deps {
out = append(out, d.Name)
}
return out
}
// checkNoCycle 在现有 edges 基础上临时加入 candidate -> its deps
// 用白/灰/黑三色 DFS 检测是否成环。
func checkNoCycle(edges map[string][]string, candidate string, candidateDeps []string) error {
// 构建临时图副本
graph := make(map[string][]string, len(edges)+1)
for k, v := range edges {
graph[k] = v
}
graph[candidate] = candidateDeps
const (
white = 0
gray = 1
black = 2
)
color := make(map[string]int)
var visit func(node string, stack []string) error
visit = func(node string, stack []string) error {
color[node] = gray
stack = append(stack, node)
for _, next := range graph[node] {
switch color[next] {
case gray:
return fmt.Errorf("检测到依赖环: %v -> %s", stack, next)
case white:
if err := visit(next, stack); err != nil {
return err
}
}
}
color[node] = black
return nil
}
return visit(candidate, nil)
}

View File

@@ -0,0 +1,143 @@
package kernel
import (
"testing"
"github.com/Masterminds/semver/v3"
contractv1 "goaiapi/proto/contract/v1"
)
// mkReg 构造一个带预置插件的注册表,便于 resolveManifest 测试。
func mkReg(plugins ...*PluginInfo) *Registry {
reg := NewRegistry()
for _, p := range plugins {
reg.byName[p.Name] = p
reg.edges[p.Name] = depNames(p.Dependencies)
}
return reg
}
func mustVer(t *testing.T, v string) *semver.Version {
t.Helper()
sv, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("解析版本 %q: %v", v, err)
}
return sv
}
func dep(name, constraint string) *contractv1.Dependency {
return &contractv1.Dependency{Name: name, VersionConstraint: constraint}
}
func manifest(name, version string, tier contractv1.PluginTier, deps ...*contractv1.Dependency) *contractv1.GetManifestResponse {
return &contractv1.GetManifestResponse{Name: name, Version: version, Tier: tier, Deps: deps}
}
func TestResolveBaseNoDeps(t *testing.T) {
reg := mkReg()
tier, err := resolveManifest(reg, manifest("echo-base", "1.0.0", contractv1.PluginTier_TIER_BASE))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_BASE {
t.Errorf("tier = %v, want BASE", tier)
}
}
func TestResolveMidDependsOnBase(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
tier, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=1.0.0 <2.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_MID {
t.Errorf("tier = %v, want MID", tier)
}
}
func TestResolveComplexDependsOnMid(t *testing.T) {
reg := mkReg(
&PluginInfo{Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"), Tier: contractv1.PluginTier_TIER_BASE},
&PluginInfo{Name: "transform-mid", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"), Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []*contractv1.Dependency{dep("echo-base", ">=1.0.0")}},
)
tier, err := resolveManifest(reg, manifest("streaming-demo", "1.0.0",
contractv1.PluginTier_TIER_COMPLEX,
dep("echo-base", ">=1.0.0"), dep("transform-mid", ">=1.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_COMPLEX {
t.Errorf("tier = %v, want COMPLEX", tier)
}
}
func TestResolveMissingDependency(t *testing.T) {
reg := mkReg()
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=1.0.0")))
if err == nil {
t.Fatal("期望缺依赖报错,得到 nil")
}
}
func TestResolveUnsatisfiedConstraint(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_MID, dep("echo-base", ">=2.0.0")))
if err == nil {
t.Fatal("期望版本不满足约束报错,得到 nil")
}
}
func TestResolveTierMismatch(t *testing.T) {
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
// 依赖 base应为 MID却自报 BASE。
_, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_BASE, dep("echo-base", ">=1.0.0")))
if err == nil {
t.Fatal("期望 tier 自报不符报错,得到 nil")
}
}
func TestResolveCycle(t *testing.T) {
// 预置 BB 依赖候选 A候选 A 又依赖 B —— 成环。
reg := mkReg(&PluginInfo{
Name: "B", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []*contractv1.Dependency{dep("A", ">=1.0.0")},
})
reg.edges["B"] = []string{"A"}
_, err := resolveManifest(reg, manifest("A", "1.0.0",
contractv1.PluginTier_TIER_COMPLEX, dep("B", ">=1.0.0")))
if err == nil {
t.Fatal("期望检测到依赖环,得到 nil")
}
}
func TestResolveUnspecifiedTierAccepted(t *testing.T) {
// 自报 UNSPECIFIED 时不强制比对,直接采用复算结果。
reg := mkReg(&PluginInfo{
Name: "echo-base", Version: "1.0.0", SemVer: mustVer(t, "1.0.0"),
Tier: contractv1.PluginTier_TIER_BASE,
})
tier, err := resolveManifest(reg, manifest("transform-mid", "1.0.0",
contractv1.PluginTier_TIER_UNSPECIFIED, dep("echo-base", ">=1.0.0")))
if err != nil {
t.Fatalf("resolveManifest: %v", err)
}
if tier != contractv1.PluginTier_TIER_MID {
t.Errorf("tier = %v, want MID", tier)
}
}

View File

@@ -0,0 +1,47 @@
// Package kernel 是插件化内核的纯基础设施:插件注册表、加载器、依赖解析、
// InvocationBroker 服务端、动态加载控制端口。内核不含任何业务逻辑,
// relay 的全部能力转发、留存、解析、dashboard都由插件实现。
package kernel
import (
"sync"
"github.com/Masterminds/semver/v3"
plugin "github.com/hashicorp/go-plugin"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
// PluginInfo 是内核对一个已加载插件的记录。
type PluginInfo struct {
Name string
Version string
SemVer *semver.Version
Tier contractv1.PluginTier
Dependencies []*contractv1.Dependency
Capabilities []string
Healthy bool
BinaryPath string
// Conn 是插件的裸 gRPC 连接Broker 用它路由 Invoke。
Conn *grpc.ClientConn
// Client 是 go-plugin 生命周期句柄,用于 Kill 插件进程。
Client *plugin.Client
}
// Registry 持有所有已加载插件及其依赖边(用于环检测)。
type Registry struct {
mu sync.RWMutex
byName map[string]*PluginInfo
// edges: 插件名 -> 它依赖的插件名集合(环检测用)
edges map[string][]string
}
// NewRegistry 创建空注册表。
func NewRegistry() *Registry {
return &Registry{
byName: make(map[string]*PluginInfo),
edges: make(map[string][]string),
}
}

View File

@@ -1,186 +0,0 @@
package main
import (
"bytes"
"flag"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
"time"
)
// 命令行参数:本地监听地址 + 上游地址 + 数据目录
var (
listenAddr = flag.String("l", "127.0.0.1:8080", "本地监听地址 host:port")
upstream = flag.String("u", "https://api.ai.pulsareon.com", "上游地址 scheme://host[:port]")
dataDir = flag.String("d", "./data", "留存数据目录")
webAddr = flag.String("a", "127.0.0.1:8081", "Web 管理界面地址 host:port空字符串关闭")
)
var connID atomic.Int64
func main() {
flag.Parse()
target, err := url.Parse(*upstream)
if err != nil {
log.Fatalf("上游地址解析失败 %s: %v", *upstream, err)
}
rec, err := NewRecorder(*dataDir)
if err != nil {
log.Fatalf("初始化留存模块失败: %v", err)
}
defer rec.Close()
// Web 管理界面(独立端口,只读)。空地址则关闭。
if *webAddr != "" {
go startWebUI(*webAddr, rec)
}
proxy := &httputil.ReverseProxy{
// Director 改写出站请求:指向上游,保持原路径
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host // 让上游看到正确的 Host 头
},
// ModifyResponse 在响应返回客户端前介入,做全量留存
ModifyResponse: recordResponse(rec),
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
log.Printf("[!] 转发失败 %s %s: %v", r.Method, r.URL.Path, e)
http.Error(w, "proxy error: "+e.Error(), http.StatusBadGateway)
},
}
// 外层 handler抓请求原文 + 计时,再交给 proxy
handler := func(w http.ResponseWriter, r *http.Request) {
// seq 仅是本进程内的日志序号,方便把同一次请求的进/出两条日志对上;
// 它不是持久化主键——真正的主键由 SQLite AUTOINCREMENT 在 Save 时分配。
seq := connID.Add(1)
start := time.Now()
// 读取并缓存请求体(读完要还回去,否则 proxy 转发不到)
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
// 把本次调用的上下文挂到 request 上,供 ModifyResponse 取用。
// ID 留空,由 Recorder.Save 写库后回填权威主键。
entry := &Entry{
Timestamp: start,
ClientIP: clientIP(r),
Method: r.Method,
Path: r.URL.Path,
ReqHeader: flattenHeader(r.Header),
ReqBody: reqBody,
}
r = r.WithContext(withEntry(r.Context(), entry))
log.Printf("[+] #%d %s %s from %s", seq, r.Method, r.URL.Path, entry.ClientIP)
proxy.ServeHTTP(w, r)
}
srv := &http.Server{
Addr: *listenAddr,
Handler: http.HandlerFunc(handler),
}
log.Printf("反向代理启动: %s -> %s (数据目录 %s)", *listenAddr, *upstream, *dataDir)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("服务退出: %v", err)
}
}
// recordResponse 返回一个 ModifyResponse 回调,做全量留存 + usage 解析。
//
// 非流式:直接 io.ReadAll 读完响应体、解析 JSON usage、落库。
// 流式SSE不能 io.ReadAll会缓冲整条流客户端要等所有 chunk 到齐才收到第一个字)。
// 改用 streamRecorder 包住 resp.Body——数据被客户端读取时同步流过
// 一边原样转发、一边拼回全量原文并解析 usage读到 EOF 时回调落库。
func recordResponse(rec *Recorder) func(*http.Response) error {
return func(resp *http.Response) error {
entry := entryFrom(resp.Request.Context())
if entry == nil {
return nil // 没有上下文,跳过留存但不影响转发
}
entry.Status = resp.StatusCode
entry.RespHeader = flattenHeader(resp.Header)
entry.IsStream = isStream(resp)
if entry.IsStream {
// 流式:包装 body落库延迟到流结束onDone时进行。
format := guessFormatByPath(entry.Path)
src := resp.Body
resp.Body = newStreamRecorder(src, format, func(raw []byte, in, out int, model string) {
entry.RespBody = raw
entry.DurationMs = time.Since(entry.Timestamp).Milliseconds()
entry.InputTokens = in
entry.OutputTokens = out
entry.TotalTokens = in + out
if model != "" {
entry.Model = model
}
// 流式响应体里没有顶层 usage 时,仍按路径标注格式
entry.APIFormat = format
saveEntry(rec, entry)
})
return nil
}
// 非流式:读完响应体并还回去(客户端还要收)
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log.Printf("[!] #%d 读取响应体失败: %v", entry.ID, err)
}
resp.Body = io.NopCloser(bytes.NewReader(respBody))
entry.RespBody = respBody
entry.DurationMs = time.Since(entry.Timestamp).Milliseconds()
parseUsage(entry, respBody)
saveEntry(rec, entry)
return nil
}
}
// saveEntry 落库并打印一条结果日志,统一非流式/流式两条路径的收尾。
func saveEntry(rec *Recorder, entry *Entry) {
if err := rec.Save(entry); err != nil {
log.Printf("[!] 留存失败: %v", err)
return
}
log.Printf("[-] #%d done status=%d %dms model=%s in=%d out=%d stream=%v",
entry.ID, entry.Status, entry.DurationMs, entry.Model,
entry.InputTokens, entry.OutputTokens, entry.IsStream)
}
// isStream 判断响应是否为 SSE 流式。
func isStream(resp *http.Response) bool {
ct := resp.Header.Get("Content-Type")
return strings.Contains(strings.ToLower(ct), "text/event-stream")
}
// clientIP 取客户端 IP去掉端口
func clientIP(r *http.Request) string {
ip := r.RemoteAddr
if i := strings.LastIndex(ip, ":"); i >= 0 {
ip = ip[:i]
}
return ip
}
// flattenHeader 把 http.Header 拍平成单层 map便于 JSON 留存。
func flattenHeader(h http.Header) map[string]string {
m := make(map[string]string, len(h))
for k, v := range h {
m[k] = strings.Join(v, ", ")
}
return m
}

View File

@@ -0,0 +1,235 @@
// dashboard-mid依赖基本插件的插件MID依赖 recorder-base实现 DashboardService。
// 内核把 dashboard 路径的请求经 broker 转给本插件的 Render本插件渲染单页前端
// 或把 /api/* 请求经 broker 转给 recorder-base 取数据后回传 JSON。
// 逻辑迁自原 web.go / webui.go前端 HTML 在 webui.go
package main
import (
"context"
"encoding/json"
"strconv"
"strings"
"sync"
"time"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
const (
recorderListMethod = "/contract.v1.RecorderService/List"
recorderReadBlobMethod = "/contract.v1.RecorderService/ReadBlob"
recorderStatsMethod = "/contract.v1.RecorderService/Stats"
)
type dashboardServer struct {
contractv1.UnimplementedDashboardServiceServer
mu sync.Mutex
kc *pluginsdk.KernelClient
}
func (s *dashboardServer) kernelClient() (*pluginsdk.KernelClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.kc != nil {
return s.kc, nil
}
kc, err := pluginsdk.NewKernelClient()
if err != nil {
return nil, err
}
s.kc = kc
return kc, nil
}
// Render 按 path 路由:首页返回 dashboard HTML/api/* 经 broker 调 recorder-base
// 取数据后回传 JSON。query 是原始 query string。
func (s *dashboardServer) Render(ctx context.Context, req *contractv1.RenderRequest) (*contractv1.RenderResponse, error) {
path := req.Path
switch {
case path == "/" || path == "":
return &contractv1.RenderResponse{
Status: 200,
ContentType: "text/html; charset=utf-8",
Body: []byte(dashboardHTML),
}, nil
case path == "/api/stats":
return s.renderStats(ctx)
case path == "/api/records":
return s.renderRecords(ctx, req.Query)
case strings.HasPrefix(path, "/api/records/"):
idStr := strings.TrimPrefix(path, "/api/records/")
return s.renderRecordDetail(ctx, idStr)
default:
return &contractv1.RenderResponse{Status: 404, ContentType: "text/plain; charset=utf-8", Body: []byte("not found")}, nil
}
}
func (s *dashboardServer) renderStats(ctx context.Context) (*contractv1.RenderResponse, error) {
kc, err := s.kernelClient()
if err != nil {
return errJSON(502, err.Error()), nil
}
payload, _ := pluginsdk.MarshalRequest(&contractv1.StatsRequest{})
respBytes, iErr := kc.Invoke(ctx, "recorder-base", recorderStatsMethod, payload)
if iErr != nil {
return errJSON(502, iErr.Error()), nil
}
var stats contractv1.StatsResponse
if err := pluginsdk.UnmarshalResponse(respBytes, &stats); err != nil {
return errJSON(500, err.Error()), nil
}
byModel := make([]map[string]any, 0, len(stats.ByModel))
for _, m := range stats.ByModel {
byModel = append(byModel, map[string]any{
"model": m.Model, "count": m.Count,
"input_tokens": m.InputTokens, "output_tokens": m.OutputTokens,
"total_tokens": m.TotalTokens,
})
}
return okJSON(map[string]any{
"total_requests": stats.TotalRequests,
"success_count": stats.SuccessCount,
"error_count": stats.ErrorCount,
"total_tokens": stats.TotalTokens,
"by_model": byModel,
}), nil
}
func (s *dashboardServer) renderRecords(ctx context.Context, query string) (*contractv1.RenderResponse, error) {
kc, err := s.kernelClient()
if err != nil {
return errJSON(502, err.Error()), nil
}
q := parseQuery(query)
listReq := &contractv1.ListRequest{
Model: q["model"],
Format: q["format"],
Limit: int32(atoiDefault(q["limit"], 100)),
Offset: int32(atoiDefault(q["offset"], 0)),
Status: int32(atoiDefault(q["status"], 0)),
}
payload, _ := pluginsdk.MarshalRequest(listReq)
respBytes, iErr := kc.Invoke(ctx, "recorder-base", recorderListMethod, payload)
if iErr != nil {
return errJSON(502, iErr.Error()), nil
}
var list contractv1.ListResponse
if err := pluginsdk.UnmarshalResponse(respBytes, &list); err != nil {
return errJSON(500, err.Error()), nil
}
records := make([]map[string]any, 0, len(list.Records))
for _, r := range list.Records {
records = append(records, recordView(r))
}
return okJSON(map[string]any{"total": list.Total, "records": records}), nil
}
func (s *dashboardServer) renderRecordDetail(ctx context.Context, idStr string) (*contractv1.RenderResponse, error) {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return &contractv1.RenderResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: []byte("无效 id")}, nil
}
kc, err := s.kernelClient()
if err != nil {
return errJSON(502, err.Error()), nil
}
payload, _ := pluginsdk.MarshalRequest(&contractv1.ReadBlobRequest{Id: id})
respBytes, iErr := kc.Invoke(ctx, "recorder-base", recorderReadBlobMethod, payload)
if iErr != nil {
return errJSON(502, iErr.Error()), nil
}
var rb contractv1.ReadBlobResponse
if err := pluginsdk.UnmarshalResponse(respBytes, &rb); err != nil {
return errJSON(500, err.Error()), nil
}
if rb.Error != "" || rb.Entry == nil {
return &contractv1.RenderResponse{Status: 404, ContentType: "text/plain; charset=utf-8", Body: []byte(rb.Error)}, nil
}
e := rb.Entry
ts := time.UnixMilli(e.TimestampMs)
return okJSON(map[string]any{
"id": e.Id,
"timestamp": ts.Format(time.RFC3339Nano),
"client_ip": e.ClientIp,
"method": e.Method,
"path": e.Path,
"req_header": e.ReqHeader,
"req_body": string(e.ReqBody),
"status": e.Status,
"resp_header": e.RespHeader,
"resp_body": string(e.RespBody),
"duration_ms": e.DurationMs,
}), nil
}
// recordView 把 TrafficEntry 投影为前端期望的列表字段endpoint = path
func recordView(r *contractv1.TrafficEntry) map[string]any {
return map[string]any{
"id": r.Id,
"ts": r.TimestampMs,
"client_ip": r.ClientIp,
"api_format": r.ApiFormat,
"endpoint": r.Path,
"model": r.Model,
"input_tokens": r.InputTokens,
"output_tokens": r.OutputTokens,
"total_tokens": r.TotalTokens,
"is_stream": r.IsStream,
"duration_ms": r.DurationMs,
"status": r.Status,
}
}
func okJSON(v any) *contractv1.RenderResponse {
body, _ := json.Marshal(v)
return &contractv1.RenderResponse{Status: 200, ContentType: "application/json; charset=utf-8", Body: body}
}
func errJSON(status int32, msg string) *contractv1.RenderResponse {
body, _ := json.Marshal(map[string]string{"error": msg})
return &contractv1.RenderResponse{Status: status, ContentType: "application/json; charset=utf-8", Body: body}
}
// parseQuery 解析原始 query string 为 map仅取每键首值够用
func parseQuery(raw string) map[string]string {
out := make(map[string]string)
for _, pair := range strings.Split(raw, "&") {
if pair == "" {
continue
}
k, v, _ := strings.Cut(pair, "=")
out[k] = v
}
return out
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func main() {
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "dashboard",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []pluginsdk.Dependency{
{Name: "recorder-base", VersionConstraint: ">=1.0.0 <2.0.0"},
},
Capabilities: []string{"DashboardService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterDashboardServiceServer(s, &dashboardServer{}) },
},
})
}

View File

@@ -2,6 +2,7 @@ package main
// dashboardHTML 是只读管理界面的单页前端(原生 JS无外部依赖
// 通过 /api/stats、/api/records、/api/records/{id} 拉数据。
// 迁自原 webui.go前端不变。
const dashboardHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>

View File

@@ -0,0 +1,32 @@
// echo-base基本插件BASE无依赖实现 EchoService。
package main
import (
"context"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
type echoServer struct {
contractv1.UnimplementedEchoServiceServer
}
func (s *echoServer) Echo(_ context.Context, req *contractv1.EchoRequest) (*contractv1.EchoResponse, error) {
return &contractv1.EchoResponse{Message: req.Message, EchoedBy: "echo-base"}, nil
}
func main() {
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "echo-base",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_BASE,
Capabilities: []string{"EchoService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterEchoServiceServer(s, &echoServer{}) },
},
})
}

View File

@@ -0,0 +1,198 @@
// proxy-complex复杂插件COMPLEX依赖 usage-base + recorder-base实现 ProxyService。
// 内核把外部流量经 broker 转给本插件的 Handle本插件转发到上游
// 再经 broker 调 usage-base 解析用量、调 recorder-base 留存。
// 反向代理转发逻辑迁自原 main.go。
package main
import (
"bytes"
"context"
"flag"
"io"
"net/http"
"strings"
"sync"
"time"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
const (
usageParseBodyMethod = "/contract.v1.UsageService/ParseBody"
usageParseStreamMethod = "/contract.v1.UsageService/ParseStream"
recorderSaveMethod = "/contract.v1.RecorderService/Save"
)
// upstream 由命令行参数指定,默认与原 relay 一致。
var upstream = flag.String("u", "https://api.ai.pulsareon.com", "上游地址 scheme://host[:port]")
type proxyServer struct {
contractv1.UnimplementedProxyServiceServer
upstream string
client *http.Client
mu sync.Mutex
kc *pluginsdk.KernelClient
}
func (s *proxyServer) kernelClient() (*pluginsdk.KernelClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.kc != nil {
return s.kc, nil
}
kc, err := pluginsdk.NewKernelClient()
if err != nil {
return nil, err
}
s.kc = kc
return kc, nil
}
// Handle 接收内核转来的请求:转发到上游,解析用量,触发留存,回传响应。
func (s *proxyServer) Handle(ctx context.Context, req *contractv1.HandleRequest) (*contractv1.HandleResponse, error) {
start := time.Now()
// 构造发往上游的请求。
target := strings.TrimRight(s.upstream, "/") + req.Path
if req.Query != "" {
target += "?" + req.Query
}
var bodyReader io.Reader
if len(req.ReqBody) > 0 {
bodyReader = bytes.NewReader(req.ReqBody)
}
outReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
if err != nil {
return &contractv1.HandleResponse{Error: "构造上游请求: " + err.Error()}, nil
}
for k, v := range req.ReqHeader {
outReq.Header.Set(k, v)
}
// 让上游看到正确的 Host 头由 http.Client 依 URL 自动处理。
resp, err := s.client.Do(outReq)
if err != nil {
return &contractv1.HandleResponse{Error: "转发上游: " + err.Error()}, nil
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return &contractv1.HandleResponse{Error: "读上游响应: " + err.Error()}, nil
}
isStream := strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream")
durationMs := time.Since(start).Milliseconds()
// 经 broker 解析用量(失败不影响转发回传)。
usage := s.parseUsage(ctx, req.Path, respBody, isStream)
// 经 broker 留存(失败不影响转发回传)。
s.record(ctx, req, resp, respBody, durationMs, isStream, usage)
return &contractv1.HandleResponse{
Status: int32(resp.StatusCode),
RespHeader: flattenHeader(resp.Header),
RespBody: respBody,
}, nil
}
// parseUsage 经 broker 调 usage-base 解析用量。
func (s *proxyServer) parseUsage(ctx context.Context, path string, body []byte, isStream bool) *contractv1.UsageInfo {
kc, err := s.kernelClient()
if err != nil {
return nil
}
var (
method string
payload []byte
mErr error
)
if isStream {
method = usageParseStreamMethod
payload, mErr = pluginsdk.MarshalRequest(&contractv1.ParseStreamRequest{Raw: body, Path: path})
} else {
method = usageParseBodyMethod
payload, mErr = pluginsdk.MarshalRequest(&contractv1.ParseBodyRequest{Body: body, Path: path})
}
if mErr != nil {
return nil
}
respBytes, iErr := kc.Invoke(ctx, "usage-base", method, payload)
if iErr != nil {
return nil
}
var info contractv1.UsageInfo
if pluginsdk.UnmarshalResponse(respBytes, &info) != nil {
return nil
}
return &info
}
// record 经 broker 调 recorder-base 留存一条 TrafficEntry。
func (s *proxyServer) record(ctx context.Context, req *contractv1.HandleRequest, resp *http.Response, respBody []byte, durationMs int64, isStream bool, usage *contractv1.UsageInfo) {
kc, err := s.kernelClient()
if err != nil {
return
}
entry := &contractv1.TrafficEntry{
TimestampMs: time.Now().UnixMilli(),
ClientIp: req.ClientIp,
Method: req.Method,
Path: req.Path,
ReqHeader: req.ReqHeader,
ReqBody: req.ReqBody,
Status: int32(resp.StatusCode),
RespHeader: flattenHeader(resp.Header),
RespBody: respBody,
DurationMs: durationMs,
IsStream: isStream,
}
if usage != nil {
entry.ApiFormat = usage.ApiFormat
entry.Model = usage.Model
entry.InputTokens = usage.InputTokens
entry.OutputTokens = usage.OutputTokens
entry.TotalTokens = usage.TotalTokens
}
payload, mErr := pluginsdk.MarshalRequest(&contractv1.SaveRequest{Entry: entry})
if mErr != nil {
return
}
_, _ = kc.Invoke(ctx, "recorder-base", recorderSaveMethod, payload)
}
func flattenHeader(h http.Header) map[string]string {
m := make(map[string]string, len(h))
for k, v := range h {
m[k] = strings.Join(v, ", ")
}
return m
}
func main() {
flag.Parse()
srv := &proxyServer{
upstream: *upstream,
client: &http.Client{},
}
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "proxy",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_COMPLEX,
Dependencies: []pluginsdk.Dependency{
{Name: "usage-base", VersionConstraint: ">=1.0.0 <2.0.0"},
{Name: "recorder-base", VersionConstraint: ">=1.0.0 <2.0.0"},
},
Capabilities: []string{"ProxyService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterProxyServiceServer(s, srv) },
},
})
}

View File

@@ -0,0 +1,310 @@
// recorder-base基本插件BASE无依赖实现 RecorderService。
// 负责全量留存:原文落盘 blob + SQLite 索引,以及列表/详情/聚合查询。
// 逻辑迁自原 recorder.go / query.go。
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
_ "modernc.org/sqlite"
)
// dataDir 由命令行参数指定(插件进程由内核以 exec 启动flag 可经 Cmd.Args 传入;
// 默认 ./data 与原 relay 行为一致)。
var dataDir = flag.String("d", "./data", "留存数据目录")
// recorderServer 实现 RecorderServiceSQLite 索引 + blob 原文落盘。
type recorderServer struct {
contractv1.UnimplementedRecorderServiceServer
dataDir string
db *sql.DB
mu sync.Mutex // 串行化写库sqlite 单写者
}
// blob 落盘的原文结构:请求 + 响应完整内容。
type blob struct {
ID int64 `json:"id"`
Timestamp string `json:"timestamp"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
Path string `json:"path"`
ReqHeader map[string]string `json:"req_header"`
ReqBody string `json:"req_body"`
Status int `json:"status"`
RespHeader map[string]string `json:"resp_header"`
RespBody string `json:"resp_body"`
DurationMs int64 `json:"duration_ms"`
}
func newRecorder(dir string) (*recorderServer, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("创建数据目录: %w", err)
}
dbPath := filepath.Join(dir, "usage.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("打开 sqlite: %w", err)
}
for _, pragma := range []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA busy_timeout=5000;",
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, fmt.Errorf("设置 pragma %q: %w", pragma, err)
}
}
schema := `
CREATE TABLE IF NOT EXISTS traffic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
client_ip TEXT,
api_format TEXT,
endpoint TEXT,
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
is_stream INTEGER,
duration_ms INTEGER,
status INTEGER,
blob_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_traffic_ts ON traffic(ts);
CREATE INDEX IF NOT EXISTS idx_traffic_model ON traffic(model);`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("建表: %w", err)
}
return &recorderServer{dataDir: dir, db: db}, nil
}
// Save 把一条 TrafficEntry 写入 SQLite 索引并将原文落盘。
// 先 INSERT 拿自增 id再用它命名 blob 文件并回填 blob_path。
func (s *recorderServer) Save(_ context.Context, req *contractv1.SaveRequest) (*contractv1.SaveResponse, error) {
e := req.Entry
if e == nil {
return &contractv1.SaveResponse{Error: "空 entry"}, nil
}
ts := time.UnixMilli(e.TimestampMs)
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(
`INSERT INTO traffic
(ts, client_ip, api_format, endpoint, model,
input_tokens, output_tokens, total_tokens, is_stream,
duration_ms, status, blob_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
e.TimestampMs, e.ClientIp, e.ApiFormat, e.Path, e.Model,
e.InputTokens, e.OutputTokens, e.TotalTokens, boolToInt(e.IsStream),
e.DurationMs, e.Status, "",
)
if err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("写索引: %v", err)}, nil
}
id, err := res.LastInsertId()
if err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("取自增 id: %v", err)}, nil
}
day := ts.Format("2006-01-02")
blobDir := filepath.Join(s.dataDir, "blobs", day)
if err := os.MkdirAll(blobDir, 0o700); err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("创建 blob 目录: %v", err)}, nil
}
rel := filepath.Join("blobs", day, fmt.Sprintf("%d.json", id))
abs := filepath.Join(s.dataDir, rel)
b := blob{
ID: id,
Timestamp: ts.Format(time.RFC3339Nano),
ClientIP: e.ClientIp,
Method: e.Method,
Path: e.Path,
ReqHeader: e.ReqHeader,
ReqBody: string(e.ReqBody),
Status: int(e.Status),
RespHeader: e.RespHeader,
RespBody: string(e.RespBody),
DurationMs: e.DurationMs,
}
data, err := json.MarshalIndent(b, "", " ")
if err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("序列化原文: %v", err)}, nil
}
if err := os.WriteFile(abs, data, 0o600); err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("写原文: %v", err)}, nil
}
if _, err := s.db.Exec("UPDATE traffic SET blob_path = ? WHERE id = ?", rel, id); err != nil {
return &contractv1.SaveResponse{Error: fmt.Sprintf("回填 blob_path: %v", err)}, nil
}
return &contractv1.SaveResponse{Id: id}, nil
}
// List 按条件查询索引记录,按 id 倒序;返回本页记录(不含原文)+ 总数。
func (s *recorderServer) List(_ context.Context, req *contractv1.ListRequest) (*contractv1.ListResponse, error) {
var where []string
var args []any
if req.Model != "" {
where = append(where, "model = ?")
args = append(args, req.Model)
}
if req.Format != "" {
where = append(where, "api_format = ?")
args = append(args, req.Format)
}
if req.Status > 0 {
where = append(where, "status = ?")
args = append(args, req.Status)
}
clause := ""
if len(where) > 0 {
clause = "WHERE " + strings.Join(where, " AND ")
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM traffic "+clause, args...).Scan(&total); err != nil {
return nil, fmt.Errorf("统计总数: %w", err)
}
limit := int(req.Limit)
if limit <= 0 || limit > 500 {
limit = 100
}
q := fmt.Sprintf(`SELECT id, ts, client_ip, api_format, endpoint, model,
input_tokens, output_tokens, total_tokens, is_stream, duration_ms, status, blob_path
FROM traffic %s ORDER BY id DESC LIMIT ? OFFSET ?`, clause)
args = append(args, limit, req.Offset)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("查询列表: %w", err)
}
defer rows.Close()
out := &contractv1.ListResponse{Total: int32(total)}
for rows.Next() {
var (
rec contractv1.TrafficEntry
isStream int
endpoint string
)
if err := rows.Scan(&rec.Id, &rec.TimestampMs, &rec.ClientIp, &rec.ApiFormat, &endpoint,
&rec.Model, &rec.InputTokens, &rec.OutputTokens, &rec.TotalTokens,
&isStream, &rec.DurationMs, &rec.Status, &rec.BlobPath); err != nil {
return nil, fmt.Errorf("扫描行: %w", err)
}
rec.Path = endpoint
rec.IsStream = isStream != 0
out.Records = append(out.Records, &rec)
}
return out, rows.Err()
}
// ReadBlob 按记录 id 找到 blob_path 并读回完整原文。
func (s *recorderServer) ReadBlob(_ context.Context, req *contractv1.ReadBlobRequest) (*contractv1.ReadBlobResponse, error) {
var rel string
if err := s.db.QueryRow("SELECT blob_path FROM traffic WHERE id = ?", req.Id).Scan(&rel); err != nil {
return &contractv1.ReadBlobResponse{Error: fmt.Sprintf("查记录 %d: %v", req.Id, err)}, nil
}
abs := filepath.Join(s.dataDir, rel)
data, err := os.ReadFile(abs)
if err != nil {
return &contractv1.ReadBlobResponse{Error: fmt.Sprintf("读原文 %s: %v", rel, err)}, nil
}
var b blob
if err := json.Unmarshal(data, &b); err != nil {
return &contractv1.ReadBlobResponse{Error: fmt.Sprintf("解析原文: %v", err)}, nil
}
ts, _ := time.Parse(time.RFC3339Nano, b.Timestamp)
return &contractv1.ReadBlobResponse{Entry: &contractv1.TrafficEntry{
Id: b.ID,
TimestampMs: ts.UnixMilli(),
ClientIp: b.ClientIP,
Method: b.Method,
Path: b.Path,
ReqHeader: b.ReqHeader,
ReqBody: []byte(b.ReqBody),
Status: int32(b.Status),
RespHeader: b.RespHeader,
RespBody: []byte(b.RespBody),
DurationMs: b.DurationMs,
BlobPath: rel,
}}, nil
}
// Stats 计算整体用量聚合:总数、成功/失败、token 总量、按模型明细。
func (s *recorderServer) Stats(_ context.Context, _ *contractv1.StatsRequest) (*contractv1.StatsResponse, error) {
out := &contractv1.StatsResponse{ByModel: []*contractv1.ModelStat{}}
row := s.db.QueryRow(`SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status >= 400 OR status = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM traffic`)
if err := row.Scan(&out.TotalRequests, &out.SuccessCount, &out.ErrorCount, &out.TotalTokens); err != nil {
return nil, fmt.Errorf("汇总: %w", err)
}
rows, err := s.db.Query(`SELECT
COALESCE(NULLIF(model, ''), '(未知)') AS m,
COUNT(*),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(total_tokens), 0)
FROM traffic GROUP BY m ORDER BY SUM(total_tokens) DESC, COUNT(*) DESC`)
if err != nil {
return nil, fmt.Errorf("按模型聚合: %w", err)
}
defer rows.Close()
for rows.Next() {
var ms contractv1.ModelStat
if err := rows.Scan(&ms.Model, &ms.Count, &ms.InputTokens, &ms.OutputTokens, &ms.TotalTokens); err != nil {
return nil, fmt.Errorf("扫描模型统计: %w", err)
}
out.ByModel = append(out.ByModel, &ms)
}
return out, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func main() {
flag.Parse()
rec, err := newRecorder(*dataDir)
if err != nil {
panic(err)
}
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "recorder-base",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_BASE,
Capabilities: []string{"RecorderService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterRecorderServiceServer(s, rec) },
},
})
}

View File

@@ -0,0 +1,129 @@
// streaming-demo复杂插件COMPLEX依赖 echo-base + transform-mid
// 实现 StreamingDemoUnaryPing 经 broker 串起 echo+transform
// StreamEvents 为 server-streaming验证流式经 gRPC 的可行性)。
package main
import (
"context"
"fmt"
"sync"
"time"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
const (
echoMethod = "/contract.v1.EchoService/Echo"
transformMethod = "/contract.v1.TransformService/Transform"
)
type streamingServer struct {
contractv1.UnimplementedStreamingDemoServer
mu sync.Mutex
kc *pluginsdk.KernelClient
}
func (s *streamingServer) kernelClient() (*pluginsdk.KernelClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.kc != nil {
return s.kc, nil
}
kc, err := pluginsdk.NewKernelClient()
if err != nil {
return nil, err
}
s.kc = kc
return kc, nil
}
// UnaryPing 串起两层依赖:先经 transform-mid 变换,再经 echo-base 回显,
// pipeline 字段记录调用链路,验证 complex -> mid -> base 的跨插件调用。
func (s *streamingServer) UnaryPing(ctx context.Context, req *contractv1.PingRequest) (*contractv1.PingResponse, error) {
pipeline := "streaming-demo"
kc, err := s.kernelClient()
if err != nil {
return &contractv1.PingResponse{Message: req.Message, Pipeline: pipeline + " (no kernel)"}, nil
}
// 1) transform-mid: 大写变换
msg := req.Message
tPayload, err := pluginsdk.MarshalRequest(&contractv1.TransformRequest{Text: msg, Operation: "upper"})
if err == nil {
respBytes, iErr := kc.Invoke(ctx, "transform-mid", transformMethod, tPayload)
if iErr == nil {
var tResp contractv1.TransformResponse
if pluginsdk.UnmarshalResponse(respBytes, &tResp) == nil {
msg = tResp.Result
pipeline += " -> transform-mid"
if tResp.ViaEcho != "" {
pipeline += " (-> " + tResp.ViaEcho + ")"
}
}
}
}
// 2) echo-base: 回显
ePayload, err := pluginsdk.MarshalRequest(&contractv1.EchoRequest{Message: msg})
if err == nil {
respBytes, iErr := kc.Invoke(ctx, "echo-base", echoMethod, ePayload)
if iErr == nil {
var eResp contractv1.EchoResponse
if pluginsdk.UnmarshalResponse(respBytes, &eResp) == nil {
msg = eResp.Message
pipeline += " -> echo-base"
}
}
}
return &contractv1.PingResponse{Message: msg, Pipeline: pipeline}, nil
}
// StreamEvents 按 count/interval_ms 推送一串事件,验证 SSE-over-gRPC。
func (s *streamingServer) StreamEvents(req *contractv1.StreamRequest, stream contractv1.StreamingDemo_StreamEventsServer) error {
count := req.Count
if count <= 0 {
count = 1
}
interval := time.Duration(req.IntervalMs) * time.Millisecond
for i := int32(0); i < count; i++ {
ev := &contractv1.StreamEvent{
Sequence: i,
Payload: fmt.Sprintf("event-%d", i),
TimestampNs: time.Now().UnixNano(),
}
if err := stream.Send(ev); err != nil {
return err
}
if interval > 0 && i < count-1 {
select {
case <-stream.Context().Done():
return stream.Context().Err()
case <-time.After(interval):
}
}
}
return nil
}
func main() {
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "streaming-demo",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_COMPLEX,
Dependencies: []pluginsdk.Dependency{
{Name: "echo-base", VersionConstraint: ">=1.0.0 <2.0.0"},
{Name: "transform-mid", VersionConstraint: ">=1.0.0 <2.0.0"},
},
Capabilities: []string{"StreamingDemo"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterStreamingDemoServer(s, &streamingServer{}) },
},
})
}

View File

@@ -0,0 +1,93 @@
// transform-mid依赖基本插件的插件MID依赖 echo-base实现 TransformService。
// Transform 通过内核 broker 调 echo-base 的 Echo——不 import echo 的 Go 包,
// 只用 proto 契约 marshal/unmarshal。
package main
import (
"context"
"strings"
"sync"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
// echoMethod 是 echo-base 的 Echo 方法全路径(与契约 package contract.v1 一致)。
const echoMethod = "/contract.v1.EchoService/Echo"
type transformServer struct {
contractv1.UnimplementedTransformServiceServer
mu sync.Mutex
kc *pluginsdk.KernelClient // 懒初始化的内核客户端
}
// kernelClient 懒连内核 broker首次跨插件调用时建立
func (s *transformServer) kernelClient() (*pluginsdk.KernelClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.kc != nil {
return s.kc, nil
}
kc, err := pluginsdk.NewKernelClient()
if err != nil {
return nil, err
}
s.kc = kc
return kc, nil
}
func (s *transformServer) Transform(ctx context.Context, req *contractv1.TransformRequest) (*contractv1.TransformResponse, error) {
// 先做本地变换。
var result string
switch req.Operation {
case "upper":
result = strings.ToUpper(req.Text)
case "lower":
result = strings.ToLower(req.Text)
case "reverse":
r := []rune(req.Text)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
result = string(r)
default:
result = req.Text
}
// 经 broker 调 echo-base验证跨插件调用链路。
viaEcho := ""
kc, err := s.kernelClient()
if err == nil {
payload, mErr := pluginsdk.MarshalRequest(&contractv1.EchoRequest{Message: result})
if mErr == nil {
respBytes, iErr := kc.Invoke(ctx, "echo-base", echoMethod, payload)
if iErr == nil {
var echoResp contractv1.EchoResponse
if pluginsdk.UnmarshalResponse(respBytes, &echoResp) == nil {
viaEcho = echoResp.EchoedBy
}
}
}
}
return &contractv1.TransformResponse{Result: result, ViaEcho: viaEcho}, nil
}
func main() {
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "transform-mid",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_MID,
Dependencies: []pluginsdk.Dependency{
{Name: "echo-base", VersionConstraint: ">=1.0.0 <2.0.0"},
},
Capabilities: []string{"TransformService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterTransformServiceServer(s, &transformServer{}) },
},
})
}

View File

@@ -0,0 +1,226 @@
// usage-base基本插件BASE无依赖实现 UsageService。
// 负责从响应体解析用量信息非流式整体解析ParseBody+ 流式全文解析
// ParseStream对拼回的全量 SSE 原文跨事件累积 usage
// 逻辑迁自原 usage.go / sse.go。
package main
import (
"bytes"
"context"
"encoding/json"
"strings"
"goaiapi/pluginsdk"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
)
type usageServer struct {
contractv1.UnimplementedUsageServiceServer
}
// ParseBody 从非流式响应体解析用量信息。兼容 OpenAI / Claude 字段命名。
func (s *usageServer) ParseBody(_ context.Context, req *contractv1.ParseBodyRequest) (*contractv1.UsageInfo, error) {
info := &contractv1.UsageInfo{}
body := req.Body
if len(body) == 0 {
info.ApiFormat = "unknown"
return info, nil
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
info.ApiFormat = "unknown"
return info, nil
}
if raw, ok := m["model"]; ok {
var str string
if json.Unmarshal(raw, &str) == nil {
info.Model = str
}
}
usageRaw, ok := m["usage"]
if !ok {
info.ApiFormat = "unknown"
return info, nil
}
var u struct {
// OpenAI
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// Claude
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
if err := json.Unmarshal(usageRaw, &u); err != nil {
info.ApiFormat = "unknown"
return info, nil
}
switch {
case u.PromptTokens > 0 || u.CompletionTokens > 0 || u.TotalTokens > 0:
info.ApiFormat = "openai"
info.InputTokens = int32(u.PromptTokens)
info.OutputTokens = int32(u.CompletionTokens)
info.TotalTokens = int32(u.TotalTokens)
if info.TotalTokens == 0 {
info.TotalTokens = int32(u.PromptTokens + u.CompletionTokens)
}
case u.InputTokens > 0 || u.OutputTokens > 0:
info.ApiFormat = "claude"
info.InputTokens = int32(u.InputTokens)
info.OutputTokens = int32(u.OutputTokens)
info.TotalTokens = int32(u.InputTokens + u.OutputTokens)
default:
info.ApiFormat = "unknown"
}
return info, nil
}
// ParseStream 对拼回的全量 SSE 原文跨事件累积 usage。
func (s *usageServer) ParseStream(_ context.Context, req *contractv1.ParseStreamRequest) (*contractv1.UsageInfo, error) {
format := guessFormatByPath(req.Path)
p := &sseParser{format: format}
p.feed(req.Raw)
p.flush()
return &contractv1.UsageInfo{
ApiFormat: format,
Model: p.model,
InputTokens: int32(p.in),
OutputTokens: int32(p.out),
TotalTokens: int32(p.in + p.out),
}, nil
}
// guessFormatByPath 按路径粗略猜测 API 格式(辅助标注)。
func guessFormatByPath(path string) string {
switch {
case strings.Contains(path, "/v1/messages"):
return "claude"
case strings.Contains(path, "/v1/chat/completions"), strings.Contains(path, "/v1/completions"):
return "openai"
case strings.Contains(path, "/api/chat"), strings.Contains(path, "/api/generate"):
return "ollama"
default:
return "unknown"
}
}
// sseParser 按行累积 SSE 数据,跨 chunk 解析 OpenAI / Claude 流式 usage。
type sseParser struct {
format string
buf bytes.Buffer
in int
out int
model string
}
func (p *sseParser) feed(b []byte) {
p.buf.Write(b)
data := p.buf.Bytes()
for {
i := bytes.IndexByte(data, '\n')
if i < 0 {
break
}
line := data[:i]
p.parseLine(line)
data = data[i+1:]
}
rest := make([]byte, len(data))
copy(rest, data)
p.buf.Reset()
p.buf.Write(rest)
}
func (p *sseParser) flush() {
if p.buf.Len() > 0 {
p.parseLine(p.buf.Bytes())
p.buf.Reset()
}
}
func (p *sseParser) parseLine(line []byte) {
s := strings.TrimRight(string(line), "\r")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "data:") {
return
}
payload := strings.TrimSpace(strings.TrimPrefix(s, "data:"))
if payload == "" || payload == "[DONE]" {
return
}
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(payload), &m); err != nil {
return
}
if raw, ok := m["model"]; ok {
var mod string
if json.Unmarshal(raw, &mod) == nil && mod != "" {
p.model = mod
}
}
if raw, ok := m["usage"]; ok {
p.applyUsage(raw)
}
if raw, ok := m["message"]; ok {
var msg map[string]json.RawMessage
if json.Unmarshal(raw, &msg) == nil {
if u, ok := msg["usage"]; ok {
p.applyUsage(u)
}
if mraw, ok := msg["model"]; ok {
var mod string
if json.Unmarshal(mraw, &mod) == nil && mod != "" {
p.model = mod
}
}
}
}
}
func (p *sseParser) applyUsage(raw json.RawMessage) {
var u struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
if json.Unmarshal(raw, &u) != nil {
return
}
if u.PromptTokens > p.in {
p.in = u.PromptTokens
}
if u.InputTokens > p.in {
p.in = u.InputTokens
}
if u.CompletionTokens > p.out {
p.out = u.CompletionTokens
}
if u.OutputTokens > p.out {
p.out = u.OutputTokens
}
}
func main() {
pluginsdk.Serve(pluginsdk.ServeOpts{
Manifest: pluginsdk.Manifest{
Name: "usage-base",
Version: "1.0.0",
Tier: contractv1.PluginTier_TIER_BASE,
Capabilities: []string{"UsageService"},
},
GRPCServices: []func(*grpc.Server){
func(s *grpc.Server) { contractv1.RegisterUsageServiceServer(s, &usageServer{}) },
},
})
}

View File

@@ -0,0 +1,66 @@
package pluginsdk
import (
"fmt"
"github.com/Masterminds/semver/v3"
contractv1 "goaiapi/proto/contract/v1"
)
// ComputeTier 由依赖图复算层级——不信插件自报,内核以此校验声明。
// 无依赖 -> base
// 只依赖 base -> mid
// 依赖任一非 base -> complex
// allTiers 是「已注册插件名 -> 其层级」的快照。
func ComputeTier(deps []Dependency, allTiers map[string]contractv1.PluginTier) contractv1.PluginTier {
if len(deps) == 0 {
return contractv1.PluginTier_TIER_BASE
}
for _, d := range deps {
t, ok := allTiers[d.Name]
if !ok || t != contractv1.PluginTier_TIER_BASE {
// 依赖未知(尚未注册)或依赖非 base都算 complex
return contractv1.PluginTier_TIER_COMPLEX
}
}
return contractv1.PluginTier_TIER_MID
}
// ValidateManifest 校验 manifest 自洽:名字/版本非空、版本是合法 semver、
// 依赖约束可解析。tier 的 DAG 一致性由内核在注册时校验(这里拿不到全图)。
func ValidateManifest(m Manifest) error {
if m.Name == "" {
return fmt.Errorf("插件名为空")
}
if _, err := semver.NewVersion(m.Version); err != nil {
return fmt.Errorf("插件 %s 版本号 %q 非合法 semver: %w", m.Name, m.Version, err)
}
for _, d := range m.Dependencies {
if d.Name == "" {
return fmt.Errorf("插件 %s 有一条依赖名为空", m.Name)
}
if _, err := semver.NewConstraint(d.VersionConstraint); err != nil {
return fmt.Errorf("插件 %s 依赖 %s 的约束 %q 非法: %w",
m.Name, d.Name, d.VersionConstraint, err)
}
}
return nil
}
// toProto 把 SDK 的 Manifest 转成 GetManifest 响应。
func (m Manifest) toProto() *contractv1.GetManifestResponse {
deps := make([]*contractv1.Dependency, 0, len(m.Dependencies))
for _, d := range m.Dependencies {
deps = append(deps, &contractv1.Dependency{
Name: d.Name,
VersionConstraint: d.VersionConstraint,
})
}
caps := make([]*contractv1.Capability, 0, len(m.Capabilities))
for _, c := range m.Capabilities {
caps = append(caps, &contractv1.Capability{ServiceName: c})
}
return &contractv1.GetManifestResponse{
Name: m.Name, Version: m.Version, Tier: m.Tier, Deps: deps, Caps: caps,
}
}

View File

@@ -0,0 +1,15 @@
package pluginsdk
import "google.golang.org/protobuf/proto"
// MarshalRequest 把插件自有的 proto 请求消息序列化为 wire 字节,
// 供 KernelClient.Invoke 跨插件传递。
func MarshalRequest(m proto.Message) ([]byte, error) {
return proto.Marshal(m)
}
// UnmarshalResponse 把 KernelClient.Invoke 返回的 wire 字节解析回插件自有的
// proto 响应消息。
func UnmarshalResponse(data []byte, m proto.Message) error {
return proto.Unmarshal(data, m)
}

108
src/go/pluginsdk/server.go Normal file
View File

@@ -0,0 +1,108 @@
package pluginsdk
import (
"context"
"errors"
"os"
plugin "github.com/hashicorp/go-plugin"
contractv1 "goaiapi/proto/contract/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// ServeOpts 是插件二进制 main() 的配置。
type ServeOpts struct {
Manifest Manifest
GRPCServices []func(*grpc.Server) // 注册本插件的 typed servicemanifest 服务由 SDK 自动注册)
}
// Serve 在每个插件二进制的 main() 里调用,阻塞直到内核关闭它。
func Serve(opts ServeOpts) {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: plugin.HandshakeConfig{
ProtocolVersion: Handshake.ProtocolVersion,
MagicCookieKey: Handshake.MagicCookieKey,
MagicCookieValue: Handshake.MagicCookieValue,
},
Plugins: plugin.PluginSet{
"plugin": &grpcPlugin{manifest: opts.Manifest, registrars: opts.GRPCServices},
},
GRPCServer: plugin.DefaultGRPCServer,
})
}
// grpcPlugin 实现 go-plugin 的 GRPCPlugin。
// 服务端:注册 manifest 服务 + 插件自有 service。
// 客户端:把裸 *grpc.ClientConn 交给内核,内核可借它调用该连接上的任意 service。
type grpcPlugin struct {
plugin.Plugin
manifest Manifest
registrars []func(*grpc.Server)
}
func (p *grpcPlugin) GRPCServer(_ *plugin.GRPCBroker, s *grpc.Server) error {
contractv1.RegisterPluginManifestServer(s, &manifestServer{m: p.manifest})
for _, reg := range p.registrars {
reg(s)
}
return nil
}
func (p *grpcPlugin) GRPCClient(_ context.Context, _ *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return c, nil
}
// manifestServer 实现通用的 PluginManifest 服务。
type manifestServer struct {
contractv1.UnimplementedPluginManifestServer
m Manifest
}
func (s *manifestServer) GetManifest(context.Context, *contractv1.GetManifestRequest) (*contractv1.GetManifestResponse, error) {
return s.m.toProto(), nil
}
func (s *manifestServer) HealthCheck(context.Context, *contractv1.HealthCheckRequest) (*contractv1.HealthCheckResponse, error) {
return &contractv1.HealthCheckResponse{Healthy: true, Message: s.m.Name + " ok"}, nil
}
// ─── 跨插件调用:内核客户端 ───
// KernelEnvAddr 是内核注入给插件的环境变量名,值为内核 InvocationBroker 地址。
const KernelEnvAddr = "GOAIAPI_KERNEL_ADDR"
// KernelClient 让插件经内核调用其它插件。
type KernelClient struct {
conn *grpc.ClientConn
broker contractv1.InvocationBrokerClient
}
// NewKernelClient 从环境变量读内核地址并连上。插件首次需要跨插件调用时再调。
func NewKernelClient() (*KernelClient, error) {
addr := os.Getenv(KernelEnvAddr)
if addr == "" {
return nil, errors.New(KernelEnvAddr + " 未设置(内核未注入地址)")
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &KernelClient{conn: conn, broker: contractv1.NewInvocationBrokerClient(conn)}, nil
}
// Invoke 请内核把 methodgRPC 全路径)转发给 target 插件payload/返回值是 proto wire 字节。
func (k *KernelClient) Invoke(ctx context.Context, target, method string, payload []byte) ([]byte, error) {
resp, err := k.broker.Invoke(ctx, &contractv1.InvokeRequest{
TargetPlugin: target, Method: method, Payload: payload,
})
if err != nil {
return nil, err
}
if resp.Error != "" {
return nil, errors.New(resp.Error)
}
return resp.Result, nil
}
func (k *KernelClient) Close() error { return k.conn.Close() }

32
src/go/pluginsdk/types.go Normal file
View File

@@ -0,0 +1,32 @@
// Package pluginsdk 是插件作者 import 的公共 SDK
// 屏蔽 go-plugin 握手细节,提供 Serve 入口与跨插件调用的内核客户端。
package pluginsdk
import contractv1 "goaiapi/proto/contract/v1"
// Handshake 是内核与所有插件共享的握手配置。
// MagicCookie 不是安全机制,只用于「被当普通程序误启动时」给出友好报错。
var Handshake = struct {
ProtocolVersion uint
MagicCookieKey string
MagicCookieValue string
}{
ProtocolVersion: 1,
MagicCookieKey: "GOAIAPI_PLUGIN",
MagicCookieValue: "v1",
}
// Dependency 声明对另一个插件的依赖。
type Dependency struct {
Name string // 依赖的插件名
VersionConstraint string // semver 约束,如 ">=1.0.0 <2.0.0"
}
// Manifest 是插件的自描述信息GetManifest 时返回给内核。
type Manifest struct {
Name string
Version string // semver
Tier contractv1.PluginTier
Dependencies []Dependency
Capabilities []string // 本插件提供的 service 名
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,188 +0,0 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// newTestProxy 用被测的 Director/ModifyResponse 组装一个反向代理,指向 mock 上游。
// 这一段刻意复刻 main() 里的装配逻辑,保证测的是真实代码路径。
func newTestProxy(t *testing.T, upstreamURL string, rec *Recorder) http.Handler {
t.Helper()
target, err := url.Parse(upstreamURL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
var id atomic.Int64
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
},
ModifyResponse: recordResponse(rec),
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
entry := &Entry{
ID: id.Add(1),
Timestamp: start,
ClientIP: clientIP(r),
Method: r.Method,
Path: r.URL.Path,
ReqHeader: flattenHeader(r.Header),
ReqBody: reqBody,
}
r = r.WithContext(withEntry(r.Context(), entry))
proxy.ServeHTTP(w, r)
})
}
// TestProxyForwardAndRecord 验证非流式全链路:
// client -> proxy -> mock上游 -> proxy(留存) -> client
// 并检查响应原样回传、原文落盘完整、SQLite 索引含正确 usage。
func TestProxyForwardAndRecord(t *testing.T) {
// 1) mock 上游:模拟 OpenAI 非流式响应,带 usage
respBody := `{"id":"chatcmpl-1","model":"gpt-4o-mini","choices":[{"message":{"role":"assistant","content":"你好"}}],"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18}}`
var gotAuth, gotReqBody string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
b, _ := io.ReadAll(r.Body)
gotReqBody = string(b)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, respBody)
}))
defer upstream.Close()
// 2) recorder 落到临时目录
dir := t.TempDir()
rec, err := NewRecorder(dir)
if err != nil {
t.Fatalf("NewRecorder: %v", err)
}
defer rec.Close()
// 3) 起代理
proxy := httptest.NewServer(newTestProxy(t, upstream.URL, rec))
defer proxy.Close()
// 4) 客户端发请求(自带 key验证透传不丢
reqPayload := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
proxy.URL+"/v1/chat/completions", strings.NewReader(reqPayload))
req.Header.Set("Authorization", "Bearer sk-test-123")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("client do: %v", err)
}
gotResp, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// --- 断言 A响应原样回传给客户端 ---
if string(gotResp) != respBody {
t.Fatalf("响应未原样回传:\n want %q\n got %q", respBody, gotResp)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
// --- 断言 Bkey 透传到上游、请求体完整 ---
if gotAuth != "Bearer sk-test-123" {
t.Fatalf("key 未透传到上游: got %q", gotAuth)
}
if gotReqBody != reqPayload {
t.Fatalf("请求体未完整转发:\n want %q\n got %q", reqPayload, gotReqBody)
}
// ModifyResponse 是同步执行的(在响应回客户端前),此时记录已落盘。
// 5) 校验 SQLite 索引
db, err := sql.Open("sqlite", filepath.Join(dir, "usage.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
var (
apiFormat, model, endpoint, blobPath string
in, out, total, status int
isStream int
)
row := db.QueryRow(`SELECT api_format, model, endpoint, input_tokens, output_tokens,
total_tokens, is_stream, status, blob_path FROM traffic LIMIT 1`)
if err := row.Scan(&apiFormat, &model, &endpoint, &in, &out, &total, &isStream, &status, &blobPath); err != nil {
t.Fatalf("scan index row: %v", err)
}
if apiFormat != "openai" {
t.Errorf("api_format = %q, want openai", apiFormat)
}
if model != "gpt-4o-mini" {
t.Errorf("model = %q, want gpt-4o-mini", model)
}
if endpoint != "/v1/chat/completions" {
t.Errorf("endpoint = %q", endpoint)
}
if in != 11 || out != 7 || total != 18 {
t.Errorf("usage = in:%d out:%d total:%d, want 11/7/18", in, out, total)
}
if isStream != 0 {
t.Errorf("is_stream = %d, want 0", isStream)
}
if status != 200 {
t.Errorf("status = %d, want 200", status)
}
// --- 断言 C原文落盘完整请求/响应原文都在 ---
raw, err := os.ReadFile(filepath.Join(dir, blobPath))
if err != nil {
t.Fatalf("read blob: %v", err)
}
var b blob
if err := json.Unmarshal(raw, &b); err != nil {
t.Fatalf("unmarshal blob: %v", err)
}
if b.ReqBody != reqPayload {
t.Errorf("blob 请求原文不完整:\n want %q\n got %q", reqPayload, b.ReqBody)
}
if b.RespBody != respBody {
t.Errorf("blob 响应原文不完整:\n want %q\n got %q", respBody, b.RespBody)
}
// 安全留存的关键:客户端的 key 出现在留存的请求头里(全量留存要求)
if got := b.ReqHeader["Authorization"]; got != "Bearer sk-test-123" {
t.Errorf("留存的请求头缺少 Authorization: got %q", got)
}
t.Logf("全链路通过: model=%s in=%d out=%d blob=%s", model, in, out, blobPath)
}
// TestParseUsageClaude 单独验证 Claude 字段命名也能解析。
func TestParseUsageClaude(t *testing.T) {
body := []byte(`{"model":"claude-3-5-sonnet","usage":{"input_tokens":25,"output_tokens":40}}`)
var e Entry
parseUsage(&e, body)
if e.APIFormat != "claude" {
t.Fatalf("api_format = %q, want claude", e.APIFormat)
}
if e.InputTokens != 25 || e.OutputTokens != 40 || e.TotalTokens != 65 {
t.Fatalf("claude usage = %d/%d/%d, want 25/40/65", e.InputTokens, e.OutputTokens, e.TotalTokens)
}
}

View File

@@ -1,162 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Record 是 traffic 索引表的一行(不含原文,原文在 blob 文件里,按需取)。
type Record struct {
ID int64 `json:"id"`
TS int64 `json:"ts"` // unix 毫秒
ClientIP string `json:"client_ip"`
APIFormat string `json:"api_format"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
IsStream bool `json:"is_stream"`
DurationMs int64 `json:"duration_ms"`
Status int `json:"status"`
BlobPath string `json:"blob_path"`
}
// ListOptions 列表查询过滤条件。空值表示不过滤。
type ListOptions struct {
Limit int
Offset int
Model string // 精确匹配
Format string // 精确匹配
Status int // >0 时精确匹配
}
// List 按条件查询索引记录,按 id 倒序(最新在前);返回本页记录 + 满足条件的总数。
func (r *Recorder) List(opts ListOptions) ([]Record, int, error) {
var where []string
var args []any
if opts.Model != "" {
where = append(where, "model = ?")
args = append(args, opts.Model)
}
if opts.Format != "" {
where = append(where, "api_format = ?")
args = append(args, opts.Format)
}
if opts.Status > 0 {
where = append(where, "status = ?")
args = append(args, opts.Status)
}
clause := ""
if len(where) > 0 {
clause = "WHERE " + strings.Join(where, " AND ")
}
// 总数(用于前端分页显示)
var total int
if err := r.db.QueryRow("SELECT COUNT(*) FROM traffic "+clause, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("统计总数: %w", err)
}
limit := opts.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
q := fmt.Sprintf(`SELECT id, ts, client_ip, api_format, endpoint, model,
input_tokens, output_tokens, total_tokens, is_stream, duration_ms, status, blob_path
FROM traffic %s ORDER BY id DESC LIMIT ? OFFSET ?`, clause)
args = append(args, limit, opts.Offset)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, 0, fmt.Errorf("查询列表: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var rec Record
var isStream int
if err := rows.Scan(&rec.ID, &rec.TS, &rec.ClientIP, &rec.APIFormat, &rec.Endpoint,
&rec.Model, &rec.InputTokens, &rec.OutputTokens, &rec.TotalTokens,
&isStream, &rec.DurationMs, &rec.Status, &rec.BlobPath); err != nil {
return nil, 0, fmt.Errorf("扫描行: %w", err)
}
rec.IsStream = isStream != 0
out = append(out, rec)
}
return out, total, rows.Err()
}
// ReadBlob 按记录 id 找到 blob_path 并读回完整原文。
func (r *Recorder) ReadBlob(id int64) (*blob, error) {
var rel string
if err := r.db.QueryRow("SELECT blob_path FROM traffic WHERE id = ?", id).Scan(&rel); err != nil {
return nil, fmt.Errorf("查记录 %d: %w", id, err)
}
abs := filepath.Join(r.dataDir, rel)
data, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("读原文 %s: %w", rel, err)
}
var b blob
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("解析原文: %w", err)
}
return &b, nil
}
// ModelStat 单个模型的聚合统计。
type ModelStat struct {
Model string `json:"model"`
Count int `json:"count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
}
// Stats 整体聚合:总请求数、成功/失败数、token 总量、按模型明细。
type Stats struct {
TotalRequests int `json:"total_requests"`
SuccessCount int `json:"success_count"`
ErrorCount int `json:"error_count"`
TotalTokens int64 `json:"total_tokens"`
ByModel []ModelStat `json:"by_model"`
}
// Stats 计算整体用量聚合。
func (r *Recorder) Stats() (*Stats, error) {
s := &Stats{ByModel: []ModelStat{}}
row := r.db.QueryRow(`SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status >= 400 OR status = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM traffic`)
if err := row.Scan(&s.TotalRequests, &s.SuccessCount, &s.ErrorCount, &s.TotalTokens); err != nil {
return nil, fmt.Errorf("汇总: %w", err)
}
rows, err := r.db.Query(`SELECT
COALESCE(NULLIF(model, ''), '(未知)') AS m,
COUNT(*),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(total_tokens), 0)
FROM traffic GROUP BY m ORDER BY SUM(total_tokens) DESC, COUNT(*) DESC`)
if err != nil {
return nil, fmt.Errorf("按模型聚合: %w", err)
}
defer rows.Close()
for rows.Next() {
var ms ModelStat
if err := rows.Scan(&ms.Model, &ms.Count, &ms.InputTokens, &ms.OutputTokens, &ms.TotalTokens); err != nil {
return nil, fmt.Errorf("扫描模型统计: %w", err)
}
s.ByModel = append(s.ByModel, ms)
}
return s, rows.Err()
}

View File

@@ -1,169 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Recorder 负责全量留存:原文落盘 + SQLite 索引。
type Recorder struct {
dataDir string
db *sql.DB
mu sync.Mutex // 串行化写库sqlite 单写者
}
// blob 落盘的原文结构:请求 + 响应完整内容。
type blob struct {
ID int64 `json:"id"`
Timestamp string `json:"timestamp"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
Path string `json:"path"`
ReqHeader map[string]string `json:"req_header"`
ReqBody string `json:"req_body"`
Status int `json:"status"`
RespHeader map[string]string `json:"resp_header"`
RespBody string `json:"resp_body"`
DurationMs int64 `json:"duration_ms"`
}
// NewRecorder 创建数据目录、打开 sqlite、建表。
func NewRecorder(dir string) (*Recorder, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("创建数据目录: %w", err)
}
dbPath := filepath.Join(dir, "usage.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("打开 sqlite: %w", err)
}
// WAL 提升并发读写表现busy_timeout 避免写锁瞬时冲突直接报错
for _, pragma := range []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA busy_timeout=5000;",
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, fmt.Errorf("设置 pragma %q: %w", pragma, err)
}
}
schema := `
CREATE TABLE IF NOT EXISTS traffic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
client_ip TEXT,
api_format TEXT,
endpoint TEXT,
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
is_stream INTEGER,
duration_ms INTEGER,
status INTEGER,
blob_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_traffic_ts ON traffic(ts);
CREATE INDEX IF NOT EXISTS idx_traffic_model ON traffic(model);`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("建表: %w", err)
}
return &Recorder{dataDir: dir, db: db}, nil
}
// Save 把一条记录写入 SQLite 索引并将原文落盘。
//
// 顺序很关键:先 INSERT不带 id由 SQLite AUTOINCREMENT 分配),
// 拿回 LastInsertId() 作为权威主键,再用它命名 blob 文件并回填 e.ID。
// 这样 id 由 DB 全局唯一递增跨进程重启不再冲突blob 文件名也随之唯一。
func (r *Recorder) Save(e *Entry) error {
// 1) 先写 SQLite 索引blob_path 暂留空,拿到自增 id 后再回填。
r.mu.Lock()
res, err := r.db.Exec(
`INSERT INTO traffic
(ts, client_ip, api_format, endpoint, model,
input_tokens, output_tokens, total_tokens, is_stream,
duration_ms, status, blob_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
e.Timestamp.UnixMilli(), e.ClientIP, e.APIFormat, e.Path, e.Model,
e.InputTokens, e.OutputTokens, e.TotalTokens, boolToInt(e.IsStream),
e.DurationMs, e.Status, "",
)
if err != nil {
r.mu.Unlock()
return fmt.Errorf("写索引: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
r.mu.Unlock()
return fmt.Errorf("取自增 id: %w", err)
}
e.ID = id // 回填权威主键(供调用方日志/后续引用)
// 2) 原文落盘:按 日期/ID.json 组织,用 DB 分配的 id 命名。
day := e.Timestamp.Format("2006-01-02")
blobDir := filepath.Join(r.dataDir, "blobs", day)
if err := os.MkdirAll(blobDir, 0o700); err != nil {
r.mu.Unlock()
return fmt.Errorf("创建 blob 目录: %w", err)
}
rel := filepath.Join("blobs", day, fmt.Sprintf("%d.json", id))
abs := filepath.Join(r.dataDir, rel)
b := blob{
ID: id,
Timestamp: e.Timestamp.Format(time.RFC3339Nano),
ClientIP: e.ClientIP,
Method: e.Method,
Path: e.Path,
ReqHeader: e.ReqHeader,
ReqBody: string(e.ReqBody),
Status: e.Status,
RespHeader: e.RespHeader,
RespBody: string(e.RespBody),
DurationMs: e.DurationMs,
}
data, err := json.MarshalIndent(b, "", " ")
if err != nil {
r.mu.Unlock()
return fmt.Errorf("序列化原文: %w", err)
}
if err := os.WriteFile(abs, data, 0o600); err != nil {
r.mu.Unlock()
return fmt.Errorf("写原文: %w", err)
}
e.BlobPath = rel
// 3) 回填 blob_path 到刚插入的行。
_, err = r.db.Exec("UPDATE traffic SET blob_path = ? WHERE id = ?", rel, id)
r.mu.Unlock()
if err != nil {
return fmt.Errorf("回填 blob_path: %w", err)
}
return nil
}
// Close 关闭数据库。
func (r *Recorder) Close() error {
if r.db != nil {
return r.db.Close()
}
return nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -1,185 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"io"
"strings"
)
// streamRecorder 包在 resp.Body 外层:数据被客户端读取时同步流过,
// 一边原样转发(客户端实时收到每个 chunk一边把全量原文拼回 + 解析 SSE usage。
// 读到 EOF流结束时回调 onDone由它落库。
//
// 这是流式代理的标准做法:用一个 io.ReadCloser 包装上游 body
// 不做 io.ReadAll避免缓冲整条流导致客户端要等所有 chunk 到齐才收到第一个字。
type streamRecorder struct {
src io.ReadCloser
raw bytes.Buffer // 全量原文(拼回所有 chunk
parser *sseParser
onDone func(raw []byte, in, out int, model string)
done bool
}
func newStreamRecorder(src io.ReadCloser, format string, onDone func(raw []byte, in, out int, model string)) *streamRecorder {
return &streamRecorder{
src: src,
parser: newSSEParser(format),
onDone: onDone,
}
}
func (s *streamRecorder) Read(p []byte) (int, error) {
n, err := s.src.Read(p)
if n > 0 {
// 原样留一份,并喂给 SSE 解析器累积 usage
s.raw.Write(p[:n])
s.parser.feed(p[:n])
}
if err == io.EOF && !s.done {
s.done = true
s.parser.flush()
in, out, model := s.parser.result()
s.onDone(s.raw.Bytes(), in, out, model)
}
return n, err
}
func (s *streamRecorder) Close() error {
// 客户端提前断开等情况:若还没触发 onDone这里兜底落库一次。
if !s.done {
s.done = true
s.parser.flush()
in, out, model := s.parser.result()
s.onDone(s.raw.Bytes(), in, out, model)
}
return s.src.Close()
}
// sseParser 按行累积 SSE 数据,跨 chunk 解析 OpenAI / Claude 两家流式 usage。
//
// OpenAI 流式usage 出现在末尾带 stream_options.include_usage 的 chunk 里,
// 形如 data: {...,"usage":{"prompt_tokens":..,"completion_tokens":..}}
// 没开 include_usage 则 usage 落空(如实记 0
// Claude 流式message_start 事件给 input_tokensusage.input_tokens
// message_delta 事件累加 output_tokensusage.output_tokens需跨事件攒。
type sseParser struct {
format string
buf bytes.Buffer // 未成行的残留字节
in int
out int
model string
}
func newSSEParser(format string) *sseParser {
return &sseParser{format: format}
}
// feed 接收任意切片的字节,按行切出完整的 SSE 行交给 parseLine。
func (p *sseParser) feed(b []byte) {
p.buf.Write(b)
data := p.buf.Bytes()
for {
i := bytes.IndexByte(data, '\n')
if i < 0 {
break
}
line := data[:i]
p.parseLine(line)
data = data[i+1:]
}
// 保留未成行的尾部
rest := make([]byte, len(data))
copy(rest, data)
p.buf.Reset()
p.buf.Write(rest)
}
// flush 处理流结束时缓冲里残留的最后一行(可能没有结尾换行)。
func (p *sseParser) flush() {
if p.buf.Len() > 0 {
p.parseLine(p.buf.Bytes())
p.buf.Reset()
}
}
// parseLine 解析单行 SSE。只关心 "data:" 行里的 JSON。
func (p *sseParser) parseLine(line []byte) {
s := strings.TrimRight(string(line), "\r")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "data:") {
return
}
payload := strings.TrimSpace(strings.TrimPrefix(s, "data:"))
if payload == "" || payload == "[DONE]" {
return
}
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(payload), &m); err != nil {
return // 非 JSON 行,跳过
}
// model 字段(两家都有,取到就记)
if raw, ok := m["model"]; ok {
var mod string
if json.Unmarshal(raw, &mod) == nil && mod != "" {
p.model = mod
}
}
// usage 可能直接在顶层OpenAI 末尾 chunk
// 也可能在 message.usage 里Claude message_start
if raw, ok := m["usage"]; ok {
p.applyUsage(raw)
}
if raw, ok := m["message"]; ok {
var msg map[string]json.RawMessage
if json.Unmarshal(raw, &msg) == nil {
if u, ok := msg["usage"]; ok {
p.applyUsage(u)
}
if mraw, ok := msg["model"]; ok {
var mod string
if json.Unmarshal(mraw, &mod) == nil && mod != "" {
p.model = mod
}
}
}
}
}
// applyUsage 从一段 usage JSON 里抽 token 数,兼容两家命名。
// Claude 的 output_tokens 在 message_delta 里是累计值(取最大即可),
// input_tokens 在 message_start 给定后不再变OpenAI 末尾一次性给全量。
func (p *sseParser) applyUsage(raw json.RawMessage) {
var u struct {
// OpenAI
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
// Claude
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
if json.Unmarshal(raw, &u) != nil {
return
}
// 取较大值,避免后到的事件覆盖成 0Claude 各事件字段不全)。
if u.PromptTokens > p.in {
p.in = u.PromptTokens
}
if u.InputTokens > p.in {
p.in = u.InputTokens
}
if u.CompletionTokens > p.out {
p.out = u.CompletionTokens
}
if u.OutputTokens > p.out {
p.out = u.OutputTokens
}
}
func (p *sseParser) result() (in, out int, model string) {
return p.in, p.out, p.model
}

View File

@@ -1,103 +0,0 @@
package main
import (
"io"
"strings"
"testing"
)
// feedInChunks 把一段完整 SSE 文本按指定大小切片喂给 streamRecorder
// 模拟真实流式逐 chunk 到达(且切片边界可能落在一行中间),
// 验证跨 chunk 的行重组与 usage 累积都正确。
func drainInChunks(t *testing.T, full string, format string, chunk int) (raw []byte, in, out int, model string) {
t.Helper()
src := io.NopCloser(strings.NewReader(full))
var gotIn, gotOut int
var gotModel string
var gotRaw []byte
sr := newStreamRecorder(src, format, func(b []byte, i, o int, m string) {
gotRaw = append([]byte(nil), b...)
gotIn, gotOut, gotModel = i, o, m
})
// 像客户端那样按小 buffer 反复读,直到 EOF
buf := make([]byte, chunk)
for {
_, err := sr.Read(buf)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read: %v", err)
}
}
return gotRaw, gotIn, gotOut, gotModel
}
// TestSSEOpenAI 验证 OpenAI 流式usage 在末尾带 include_usage 的 chunk 里。
func TestSSEOpenAI(t *testing.T) {
full := strings.Join([]string{
`data: {"model":"gpt-5.5","choices":[{"delta":{"content":"O"}}]}`,
`data: {"model":"gpt-5.5","choices":[{"delta":{"content":"K"}}]}`,
`data: {"model":"gpt-5.5","choices":[],"usage":{"prompt_tokens":42,"completion_tokens":3,"total_tokens":45}}`,
`data: [DONE]`,
"",
}, "\n")
// 用一个会落在行中间的小 chunk逼真地考验跨 chunk 重组
raw, in, out, model := drainInChunks(t, full, "openai", 7)
if in != 42 || out != 3 {
t.Errorf("openai usage = in:%d out:%d, want 42/3", in, out)
}
if model != "gpt-5.5" {
t.Errorf("model = %q, want gpt-5.5", model)
}
if string(raw) != full {
t.Errorf("原文未完整拼回:\n want %q\n got %q", full, raw)
}
}
// TestSSEClaude 验证 Claude 流式input_tokens 在 message_start
// output_tokens 在 message_delta 累计(取最大值)。
func TestSSEClaude(t *testing.T) {
full := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"model":"claude-3-5-sonnet","usage":{"input_tokens":58,"output_tokens":1}}}`,
`event: content_block_delta`,
`data: {"type":"content_block_delta","delta":{"text":"hi"}}`,
`event: message_delta`,
`data: {"type":"message_delta","usage":{"output_tokens":27}}`,
`event: message_stop`,
`data: {"type":"message_stop"}`,
"",
}, "\n")
raw, in, out, model := drainInChunks(t, full, "claude", 13)
if in != 58 {
t.Errorf("claude input_tokens = %d, want 58", in)
}
if out != 27 {
t.Errorf("claude output_tokens = %d, want 27 (message_delta 累计)", out)
}
if model != "claude-3-5-sonnet" {
t.Errorf("model = %q, want claude-3-5-sonnet", model)
}
if string(raw) != full {
t.Errorf("原文未完整拼回")
}
}
// TestSSENoUsage 验证未开 include_usage 时 usage 落空但不报错、原文仍完整。
func TestSSENoUsage(t *testing.T) {
full := strings.Join([]string{
`data: {"model":"gpt-5.5","choices":[{"delta":{"content":"hi"}}]}`,
`data: [DONE]`,
"",
}, "\n")
raw, in, out, _ := drainInChunks(t, full, "openai", 1)
if in != 0 || out != 0 {
t.Errorf("无 usage 时应为 0/0, got %d/%d", in, out)
}
if string(raw) != full {
t.Errorf("原文未完整拼回")
}
}

View File

@@ -1,84 +0,0 @@
package main
import (
"encoding/json"
"strings"
)
// parseUsage 从非流式响应体里解析用量信息,结果写回 entry。
// 兼容 OpenAI、Claude 两种字段命名;识别不出就标 unknown不报错。
func parseUsage(entry *Entry, body []byte) {
if len(body) == 0 {
entry.APIFormat = "unknown"
return
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
entry.APIFormat = "unknown"
return
}
// model 字段OpenAI 和 Claude 都叫 model
if raw, ok := m["model"]; ok {
var s string
if json.Unmarshal(raw, &s) == nil {
entry.Model = s
}
}
usageRaw, ok := m["usage"]
if !ok {
entry.APIFormat = "unknown"
return
}
// usage 字段名两家不同,都尝试一遍
var u struct {
// OpenAI
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// Claude
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
if err := json.Unmarshal(usageRaw, &u); err != nil {
entry.APIFormat = "unknown"
return
}
switch {
case u.PromptTokens > 0 || u.CompletionTokens > 0 || u.TotalTokens > 0:
// OpenAI 风格
entry.APIFormat = "openai"
entry.InputTokens = u.PromptTokens
entry.OutputTokens = u.CompletionTokens
entry.TotalTokens = u.TotalTokens
if entry.TotalTokens == 0 {
entry.TotalTokens = u.PromptTokens + u.CompletionTokens
}
case u.InputTokens > 0 || u.OutputTokens > 0:
// Claude 风格
entry.APIFormat = "claude"
entry.InputTokens = u.InputTokens
entry.OutputTokens = u.OutputTokens
entry.TotalTokens = u.InputTokens + u.OutputTokens
default:
entry.APIFormat = "unknown"
}
}
// guessFormatByPath 在拿不到 usage 时,按路径粗略猜测 API 格式(仅作辅助标注)。
func guessFormatByPath(path string) string {
switch {
case strings.Contains(path, "/v1/messages"):
return "claude"
case strings.Contains(path, "/v1/chat/completions"), strings.Contains(path, "/v1/completions"):
return "openai"
case strings.Contains(path, "/api/chat"), strings.Contains(path, "/api/generate"):
return "ollama"
default:
return "unknown"
}
}

View File

@@ -1,103 +0,0 @@
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
)
// startWebUI 在独立端口起一个只读管理界面,对外提供 dashboard + JSON API。
// 单独端口避免和反代端口冲突(反代会把所有路径转发上游)。
func startWebUI(addr string, rec *Recorder) {
mux := webMux(rec)
log.Printf("Web 管理界面启动: http://%s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Printf("[!] Web 管理界面退出: %v", err)
}
}
// webMux 组装管理界面的路由,单独抽出便于测试。
func webMux(rec *Recorder) http.Handler {
mux := http.NewServeMux()
// 首页dashboard HTML
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(dashboardHTML))
})
// 聚合统计
mux.HandleFunc("GET /api/stats", func(w http.ResponseWriter, r *http.Request) {
s, err := rec.Stats()
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, s)
})
// 记录列表(支持 model/format/status/limit/offset 过滤)
mux.HandleFunc("GET /api/records", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
opts := ListOptions{
Model: q.Get("model"),
Format: q.Get("format"),
Limit: atoiDefault(q.Get("limit"), 100),
Offset: atoiDefault(q.Get("offset"), 0),
Status: atoiDefault(q.Get("status"), 0),
}
recs, total, err := rec.List(opts)
if err != nil {
writeErr(w, err)
return
}
if recs == nil {
recs = []Record{}
}
writeJSON(w, map[string]any{"total": total, "records": recs})
})
// 单条记录原文详情(含完整请求/响应)
mux.HandleFunc("GET /api/records/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "无效 id", http.StatusBadRequest)
return
}
b, err := rec.ReadBlob(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, b)
})
return mux
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("[!] 写 JSON 响应失败: %v", err)
}
}
func writeErr(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}

View File

@@ -1,188 +0,0 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// seedRecords 往 recorder 里塞几条记录,供 web 接口测试使用。
func seedRecords(t *testing.T, rec *Recorder) {
t.Helper()
now := time.Now()
seeds := []*Entry{
{ID: 1, Timestamp: now, ClientIP: "127.0.0.1", Method: "POST", Path: "/v1/chat/completions",
ReqHeader: map[string]string{"Authorization": "Bearer sk-aaa"}, ReqBody: []byte(`{"model":"gpt-5.5"}`),
Status: 200, RespHeader: map[string]string{"Content-Type": "application/json"}, RespBody: []byte(`{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`),
APIFormat: "openai", Model: "gpt-5.5", InputTokens: 10, OutputTokens: 5, TotalTokens: 15},
{ID: 2, Timestamp: now, ClientIP: "127.0.0.1", Method: "POST", Path: "/v1/messages",
ReqHeader: map[string]string{}, ReqBody: []byte(`{"model":"claude-3-5-sonnet"}`),
Status: 200, RespHeader: map[string]string{}, RespBody: []byte(`{}`),
APIFormat: "claude", Model: "claude-3-5-sonnet", InputTokens: 100, OutputTokens: 200, TotalTokens: 300},
{ID: 3, Timestamp: now, ClientIP: "10.0.0.2", Method: "POST", Path: "/v1/chat/completions",
ReqHeader: map[string]string{}, ReqBody: []byte(`{}`),
Status: 503, RespHeader: map[string]string{}, RespBody: []byte(``),
APIFormat: "unknown", Model: "", InputTokens: 0, OutputTokens: 0, TotalTokens: 0},
}
for _, e := range seeds {
if err := rec.Save(e); err != nil {
t.Fatalf("seed save #%d: %v", e.ID, err)
}
}
}
// TestWebStats 验证 /api/stats 聚合正确。
func TestWebStats(t *testing.T) {
rec, err := NewRecorder(t.TempDir())
if err != nil {
t.Fatalf("NewRecorder: %v", err)
}
defer rec.Close()
seedRecords(t, rec)
srv := httptest.NewServer(webMux(rec))
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/stats")
if err != nil {
t.Fatalf("get stats: %v", err)
}
defer resp.Body.Close()
var s Stats
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
t.Fatalf("decode stats: %v", err)
}
if s.TotalRequests != 3 {
t.Errorf("total_requests = %d, want 3", s.TotalRequests)
}
if s.SuccessCount != 2 {
t.Errorf("success_count = %d, want 2", s.SuccessCount)
}
if s.ErrorCount != 1 {
t.Errorf("error_count = %d, want 1", s.ErrorCount)
}
if s.TotalTokens != 315 {
t.Errorf("total_tokens = %d, want 315", s.TotalTokens)
}
if len(s.ByModel) != 3 {
t.Errorf("by_model 种类 = %d, want 3", len(s.ByModel))
}
}
// TestWebRecordsFilter 验证 /api/records 的过滤与分页。
func TestWebRecordsFilter(t *testing.T) {
rec, err := NewRecorder(t.TempDir())
if err != nil {
t.Fatalf("NewRecorder: %v", err)
}
defer rec.Close()
seedRecords(t, rec)
srv := httptest.NewServer(webMux(rec))
defer srv.Close()
// 无过滤3 条
if total := recordsTotal(t, srv.URL+"/api/records"); total != 3 {
t.Errorf("无过滤 total = %d, want 3", total)
}
// 按格式过滤openai 只有 1 条
if total := recordsTotal(t, srv.URL+"/api/records?format=openai"); total != 1 {
t.Errorf("format=openai total = %d, want 1", total)
}
// 按状态过滤503 只有 1 条
if total := recordsTotal(t, srv.URL+"/api/records?status=503"); total != 1 {
t.Errorf("status=503 total = %d, want 1", total)
}
// 按模型过滤
if total := recordsTotal(t, srv.URL+"/api/records?model=gpt-5.5"); total != 1 {
t.Errorf("model=gpt-5.5 total = %d, want 1", total)
}
}
func recordsTotal(t *testing.T, url string) int {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
var out struct {
Total int `json:"total"`
Records []Record `json:"records"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode records: %v", err)
}
return out.Total
}
// TestWebRecordDetail 验证 /api/records/{id} 返回完整原文(含留存的请求头)。
func TestWebRecordDetail(t *testing.T) {
rec, err := NewRecorder(t.TempDir())
if err != nil {
t.Fatalf("NewRecorder: %v", err)
}
defer rec.Close()
seedRecords(t, rec)
srv := httptest.NewServer(webMux(rec))
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/records/1")
if err != nil {
t.Fatalf("get detail: %v", err)
}
defer resp.Body.Close()
var b blob
if err := json.NewDecoder(resp.Body).Decode(&b); err != nil {
t.Fatalf("decode blob: %v", err)
}
if b.ID != 1 {
t.Errorf("id = %d, want 1", b.ID)
}
if !strings.Contains(b.ReqBody, "gpt-5.5") {
t.Errorf("req_body 缺内容: %q", b.ReqBody)
}
// 全量留存:请求头里的凭证原样可查
if b.ReqHeader["Authorization"] != "Bearer sk-aaa" {
t.Errorf("留存请求头缺 Authorization: %q", b.ReqHeader["Authorization"])
}
// 不存在的 id 应 404
resp2, _ := http.Get(srv.URL + "/api/records/999")
if resp2 != nil {
resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("不存在 id 状态 = %d, want 404", resp2.StatusCode)
}
}
}
// TestWebDashboardHTML 验证首页返回 HTML。
func TestWebDashboardHTML(t *testing.T) {
rec, err := NewRecorder(t.TempDir())
if err != nil {
t.Fatalf("NewRecorder: %v", err)
}
defer rec.Close()
srv := httptest.NewServer(webMux(rec))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatalf("get /: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
if !strings.Contains(string(body), "AI 通讯留存") {
t.Errorf("首页缺标题")
}
}

9
tools/README.md Normal file
View File

@@ -0,0 +1,9 @@
# tools — 本地工具目录(预留)
存放与本项目相关、但不属于主源码模块的本地辅助工具(如一次性脚本、调试器、客户端样例、性能测试器等)。当前为空。
约定:
- 放进来的工具应自带说明(脚本头注释或子目录 README不要依赖本目录之外的隐式状态。
- 若某工具成长为长期组成部分,考虑迁入 `src/` 作为正式包,或独立成模块。
- 跨语言客户端样例可放在此处(如 `tools/py-client/`),契约从 `proto/` 生成,见 `proto/README.md`