Compare commits
1 Commits
ddd9d43f9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b456dbb07 |
74
README.md
Normal file
74
README.md
Normal 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
79
proto/README.md
Normal 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 / JavaScript(ts-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. 通知各语言使用方按上面的命令重新生成
|
||||||
181
proto/contract/v1/contract.proto
Normal file
181
proto/contract/v1/contract.proto
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecorderService(BASE):留存 + 查询 ──
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UsageService(BASE):用量解析(非流式整体 / 流式全文) ──
|
||||||
|
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 原文
|
||||||
|
|
||||||
|
// ── ProxyService(COMPLEX):内核把外部请求转发到此,由它转上游并触发留存 ──
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DashboardService(MID,依赖 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
27
scripts/README.md
Normal 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 发现。
|
||||||
@@ -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'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path $PSScriptRoot -Parent # 仓库根目录(scripts/ 的上一级)
|
$root = Split-Path $PSScriptRoot -Parent # repo root (parent of scripts/)
|
||||||
$out = Join-Path $root 'build\relay.exe'
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
go build -o $out .
|
& $goExe build -o $relayOut ./cmd/relay
|
||||||
Write-Host "Built: $out"
|
if ($LASTEXITCODE -ne 0) { throw "build relay failed (exit $LASTEXITCODE)" }
|
||||||
|
Write-Host "Built: $relayOut"
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
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
26
scripts/genproto.ps1
Normal 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
9
src/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# src — 源码
|
||||||
|
|
||||||
|
项目的全部源码。目前只有一个 Go 模块,位于 `go/`。
|
||||||
|
|
||||||
|
| 子目录 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `go/` | Go 模块 `goaiapi`:内核、插件 SDK、契约、全部插件源码 |
|
||||||
|
|
||||||
|
模块布局、构建方式与各包职责见 `go/README.md`。
|
||||||
50
src/go/README.md
Normal file
50
src/go/README.md
Normal 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
227
src/go/cmd/relay/main.go
Normal 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
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,30 @@ module goaiapi
|
|||||||
|
|
||||||
go 1.26.3
|
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 (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
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/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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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
|
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/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/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
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 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
|||||||
91
src/go/internal/kernel/broker.go
Normal file
91
src/go/internal/kernel/broker.go
Normal 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
|
||||||
|
}
|
||||||
140
src/go/internal/kernel/control.go
Normal file
140
src/go/internal/kernel/control.go
Normal 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)
|
||||||
|
}
|
||||||
102
src/go/internal/kernel/integration_test.go
Normal file
102
src/go/internal/kernel/integration_test.go
Normal 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-base,via_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
|
||||||
73
src/go/internal/kernel/launcher.go
Normal file
73
src/go/internal/kernel/launcher.go
Normal 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
|
||||||
|
}
|
||||||
65
src/go/internal/kernel/main_test.go
Normal file
65
src/go/internal/kernel/main_test.go
Normal 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()
|
||||||
|
}
|
||||||
279
src/go/internal/kernel/registry.go
Normal file
279
src/go/internal/kernel/registry.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/go/internal/kernel/registry_test.go
Normal file
92
src/go/internal/kernel/registry_test.go
Normal 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()
|
||||||
|
}
|
||||||
103
src/go/internal/kernel/resolver.go
Normal file
103
src/go/internal/kernel/resolver.go
Normal 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. 环检测:把候选依赖临时并入 edges,DFS 着色发现回边即拒绝。
|
||||||
|
// 调用方必须持有 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)
|
||||||
|
}
|
||||||
143
src/go/internal/kernel/resolver_test.go
Normal file
143
src/go/internal/kernel/resolver_test.go
Normal 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) {
|
||||||
|
// 预置 B,B 依赖候选 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/go/internal/kernel/types.go
Normal file
47
src/go/internal/kernel/types.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/go/main.go
186
src/go/main.go
@@ -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
|
|
||||||
}
|
|
||||||
235
src/go/plugins/dashboard/main.go
Normal file
235
src/go/plugins/dashboard/main.go
Normal 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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
// dashboardHTML 是只读管理界面的单页前端(原生 JS,无外部依赖)。
|
// dashboardHTML 是只读管理界面的单页前端(原生 JS,无外部依赖)。
|
||||||
// 通过 /api/stats、/api/records、/api/records/{id} 拉数据。
|
// 通过 /api/stats、/api/records、/api/records/{id} 拉数据。
|
||||||
|
// 迁自原 webui.go,前端不变。
|
||||||
const dashboardHTML = `<!DOCTYPE html>
|
const dashboardHTML = `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
32
src/go/plugins/echo/main.go
Normal file
32
src/go/plugins/echo/main.go
Normal 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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
198
src/go/plugins/proxy/main.go
Normal file
198
src/go/plugins/proxy/main.go
Normal 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) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
310
src/go/plugins/recorder/main.go
Normal file
310
src/go/plugins/recorder/main.go
Normal 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 实现 RecorderService:SQLite 索引 + 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) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
129
src/go/plugins/streaming/main.go
Normal file
129
src/go/plugins/streaming/main.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// streaming-demo:复杂插件(COMPLEX,依赖 echo-base + transform-mid),
|
||||||
|
// 实现 StreamingDemo(UnaryPing 经 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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
93
src/go/plugins/transform/main.go
Normal file
93
src/go/plugins/transform/main.go
Normal 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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
226
src/go/plugins/usage/main.go
Normal file
226
src/go/plugins/usage/main.go
Normal 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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
66
src/go/pluginsdk/manifest.go
Normal file
66
src/go/pluginsdk/manifest.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/go/pluginsdk/marshal.go
Normal file
15
src/go/pluginsdk/marshal.go
Normal 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
108
src/go/pluginsdk/server.go
Normal 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 service(manifest 服务由 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 请内核把 method(gRPC 全路径)转发给 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
32
src/go/pluginsdk/types.go
Normal 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 名
|
||||||
|
}
|
||||||
2505
src/go/proto/contract/v1/contract.pb.go
Normal file
2505
src/go/proto/contract/v1/contract.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
1243
src/go/proto/contract/v1/contract_grpc.pb.go
Normal file
1243
src/go/proto/contract/v1/contract_grpc.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 断言 B:key 透传到上游、请求体完整 ---
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
162
src/go/query.go
162
src/go/query.go
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
185
src/go/sse.go
185
src/go/sse.go
@@ -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_tokens(usage.input_tokens),
|
|
||||||
// message_delta 事件累加 output_tokens(usage.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
|
|
||||||
}
|
|
||||||
// 取较大值,避免后到的事件覆盖成 0(Claude 各事件字段不全)。
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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("原文未完整拼回")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
103
src/go/web.go
103
src/go/web.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
9
tools/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# tools — 本地工具目录(预留)
|
||||||
|
|
||||||
|
存放与本项目相关、但不属于主源码模块的本地辅助工具(如一次性脚本、调试器、客户端样例、性能测试器等)。当前为空。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- 放进来的工具应自带说明(脚本头注释或子目录 README),不要依赖本目录之外的隐式状态。
|
||||||
|
- 若某工具成长为长期组成部分,考虑迁入 `src/` 作为正式包,或独立成模块。
|
||||||
|
- 跨语言客户端样例可放在此处(如 `tools/py-client/`),契约从 `proto/` 生成,见 `proto/README.md`。
|
||||||
Reference in New Issue
Block a user