Compare commits
3 Commits
ade410c289
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b456dbb07 | |||
| ddd9d43f9d | |||
| 1216ad0145 |
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 运行数据(usage.db 与 blobs/,含请求/响应记录,可能含敏感信息)
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# 编译产物
|
||||||
|
*.exe
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# 运行日志
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 本地说明文件(含上游密钥,不入库)
|
||||||
|
说明.md
|
||||||
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 发现。
|
||||||
54
scripts/build.ps1
Normal file
54
scripts/build.ps1
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Build script (in scripts/):
|
||||||
|
# 1) generate proto code (calls genproto.ps1)
|
||||||
|
# 2) build kernel entry cmd/relay -> build/relay.exe
|
||||||
|
# 3) build all plugins -> build/plugins/<name>.exe
|
||||||
|
# Source in src/go/, artifacts output to build/.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path $PSScriptRoot -Parent # repo root (parent of scripts/)
|
||||||
|
|
||||||
|
$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 {
|
||||||
|
& $goExe build -o $relayOut ./cmd/relay
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "build relay failed (exit $LASTEXITCODE)" }
|
||||||
|
Write-Host "Built: $relayOut"
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) build all plugins (output name matches manifest plugin name)
|
||||||
|
New-Item -ItemType Directory -Force $pluginOut | Out-Null
|
||||||
|
$plugins = @(
|
||||||
|
@{ dir = 'echo'; out = 'echo-base.exe' },
|
||||||
|
@{ dir = 'transform'; out = 'transform-mid.exe' },
|
||||||
|
@{ dir = 'streaming'; out = 'streaming-demo.exe' },
|
||||||
|
@{ dir = 'recorder'; out = 'recorder-base.exe' },
|
||||||
|
@{ dir = 'usage'; out = 'usage-base.exe' },
|
||||||
|
@{ dir = 'proxy'; out = 'proxy.exe' },
|
||||||
|
@{ dir = 'dashboard'; out = 'dashboard.exe' }
|
||||||
|
)
|
||||||
|
Push-Location $srcGo
|
||||||
|
try {
|
||||||
|
foreach ($p in $plugins) {
|
||||||
|
$target = Join-Path $pluginOut $p.out
|
||||||
|
& $goExe build -o $target ('./plugins/' + $p.dir)
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw ("build plugin " + $p.dir + " failed (exit $LASTEXITCODE)") }
|
||||||
|
Write-Host "Built plugin: $target"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "All artifacts built under build/."
|
||||||
26
scripts/genproto.ps1
Normal file
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
|
||||||
32
src/go/go.mod
Normal file
32
src/go/go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module goaiapi
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0
|
||||||
|
github.com/hashicorp/go-plugin v1.8.0
|
||||||
|
google.golang.org/grpc v1.81.1
|
||||||
|
modernc.org/sqlite v1.51.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||||
|
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/oklog/run v1.1.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
122
src/go/go.sum
Normal file
122
src/go/go.sum
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||||
|
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
|
||||||
|
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||||
|
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||||
|
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
|
||||||
|
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||||
|
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
|
||||||
|
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||||
|
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
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{}) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
232
src/go/plugins/dashboard/webui.go
Normal file
232
src/go/plugins/dashboard/webui.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// dashboardHTML 是只读管理界面的单页前端(原生 JS,无外部依赖)。
|
||||||
|
// 通过 /api/stats、/api/records、/api/records/{id} 拉数据。
|
||||||
|
// 迁自原 webui.go,前端不变。
|
||||||
|
const dashboardHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AI 通讯留存 - 管理界面</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --panel: #161b22; --border: #30363d;
|
||||||
|
--fg: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
||||||
|
--ok: #3fb950; --err: #f85149; --warn: #d29922;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||||
|
header { padding: 16px 24px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; gap: 12px; }
|
||||||
|
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||||
|
header .sub { color: var(--muted); font-size: 12px; }
|
||||||
|
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 24px; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 16px; }
|
||||||
|
.card .label { color: var(--muted); font-size: 12px; }
|
||||||
|
.card .value { font-size: 24px; font-weight: 600; margin-top: 4px; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
input, select, button { background: var(--panel); color: var(--fg);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font-size: 13px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px; white-space: nowrap; }
|
||||||
|
th { color: var(--muted); font-weight: 500; background: #1c2128; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr.row:hover { background: #1c2128; cursor: pointer; }
|
||||||
|
.badge { padding: 1px 8px; border-radius: 10px; font-size: 12px; }
|
||||||
|
.s-ok { color: var(--ok); } .s-err { color: var(--err); }
|
||||||
|
.tag { background: #1f6feb33; color: var(--accent); padding: 1px 6px;
|
||||||
|
border-radius: 4px; font-size: 11px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.pager { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
||||||
|
/* 详情抽屉 */
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||||
|
display: none; z-index: 10; }
|
||||||
|
.overlay.show { display: block; }
|
||||||
|
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw);
|
||||||
|
background: var(--panel); border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto; padding: 20px; z-index: 11; transform: translateX(100%);
|
||||||
|
transition: transform .2s; }
|
||||||
|
.drawer.show { transform: translateX(0); }
|
||||||
|
.drawer h2 { font-size: 15px; margin: 0 0 16px; }
|
||||||
|
.drawer .close { position: absolute; top: 16px; right: 20px; }
|
||||||
|
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; margin-bottom: 16px; }
|
||||||
|
.kv .k { color: var(--muted); }
|
||||||
|
pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||||||
|
font: 12px/1.5 "Consolas", "Courier New", monospace; max-height: 300px; }
|
||||||
|
.sec-title { color: var(--muted); font-size: 12px; margin: 16px 0 6px;
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>AI 通讯留存</h1>
|
||||||
|
<span class="sub">全量审计 · 只读管理界面</span>
|
||||||
|
<span class="sub" id="refreshInfo" style="margin-left:auto"></span>
|
||||||
|
<button onclick="loadAll()">刷新</button>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="cards" id="cards"></div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="fModel" placeholder="按模型过滤 (精确)" />
|
||||||
|
<select id="fFormat">
|
||||||
|
<option value="">全部格式</option>
|
||||||
|
<option value="openai">openai</option>
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="ollama">ollama</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
<input id="fStatus" placeholder="状态码 (如 200)" style="width:120px" />
|
||||||
|
<button onclick="applyFilter()">查询</button>
|
||||||
|
<button onclick="clearFilter()">清空</button>
|
||||||
|
<span class="muted" id="totalInfo"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>#</th><th>时间</th><th>客户端</th><th>格式</th><th>模型</th>
|
||||||
|
<th>路径</th><th>状态</th><th>输入</th><th>输出</th><th>合计</th>
|
||||||
|
<th>流式</th><th>耗时</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<button id="prev" onclick="prevPage()">上一页</button>
|
||||||
|
<span class="muted" id="pageInfo"></span>
|
||||||
|
<button id="next" onclick="nextPage()">下一页</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="overlay" id="overlay" onclick="closeDrawer()"></div>
|
||||||
|
<div class="drawer" id="drawer">
|
||||||
|
<button class="close" onclick="closeDrawer()">关闭</button>
|
||||||
|
<div id="detail"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PAGE = 50;
|
||||||
|
let offset = 0, total = 0;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
function fmtTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = n => String(n).padStart(2, "0");
|
||||||
|
return d.getFullYear() + "-" + p(d.getMonth()+1) + "-" + p(d.getDate()) +
|
||||||
|
" " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
||||||
|
}
|
||||||
|
function statusClass(s) { return (s >= 200 && s < 400) ? "s-ok" : "s-err"; }
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const r = await fetch("api/stats");
|
||||||
|
const s = await r.json();
|
||||||
|
const cards = [
|
||||||
|
["总请求数", s.total_requests],
|
||||||
|
["成功", s.success_count],
|
||||||
|
["失败", s.error_count],
|
||||||
|
["Token 合计", (s.total_tokens || 0).toLocaleString()],
|
||||||
|
["模型种类", (s.by_model || []).length],
|
||||||
|
];
|
||||||
|
document.getElementById("cards").innerHTML = cards.map(c =>
|
||||||
|
'<div class="card"><div class="label">' + esc(c[0]) +
|
||||||
|
'</div><div class="value">' + esc(c[1]) + '</div></div>').join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", PAGE);
|
||||||
|
params.set("offset", offset);
|
||||||
|
const m = document.getElementById("fModel").value.trim();
|
||||||
|
const f = document.getElementById("fFormat").value;
|
||||||
|
const st = document.getElementById("fStatus").value.trim();
|
||||||
|
if (m) params.set("model", m);
|
||||||
|
if (f) params.set("format", f);
|
||||||
|
if (st) params.set("status", st);
|
||||||
|
|
||||||
|
const r = await fetch("api/records?" + params.toString());
|
||||||
|
const data = await r.json();
|
||||||
|
total = data.total || 0;
|
||||||
|
const rows = data.records || [];
|
||||||
|
document.getElementById("rows").innerHTML = rows.map(rec =>
|
||||||
|
'<tr class="row" onclick="openDetail(' + rec.id + ')">' +
|
||||||
|
'<td>' + rec.id + '</td>' +
|
||||||
|
'<td>' + esc(fmtTime(rec.ts)) + '</td>' +
|
||||||
|
'<td>' + esc(rec.client_ip) + '</td>' +
|
||||||
|
'<td><span class="tag">' + esc(rec.api_format) + '</span></td>' +
|
||||||
|
'<td>' + esc(rec.model || "-") + '</td>' +
|
||||||
|
'<td class="muted">' + esc(rec.endpoint) + '</td>' +
|
||||||
|
'<td class="' + statusClass(rec.status) + '">' + rec.status + '</td>' +
|
||||||
|
'<td>' + rec.input_tokens + '</td>' +
|
||||||
|
'<td>' + rec.output_tokens + '</td>' +
|
||||||
|
'<td>' + rec.total_tokens + '</td>' +
|
||||||
|
'<td>' + (rec.is_stream ? "是" : "否") + '</td>' +
|
||||||
|
'<td class="muted">' + rec.duration_ms + 'ms</td>' +
|
||||||
|
'</tr>').join("") ||
|
||||||
|
'<tr><td colspan="12" class="muted" style="text-align:center;padding:24px">暂无记录</td></tr>';
|
||||||
|
|
||||||
|
document.getElementById("totalInfo").textContent = "共 " + total + " 条";
|
||||||
|
const from = total === 0 ? 0 : offset + 1;
|
||||||
|
const to = Math.min(offset + PAGE, total);
|
||||||
|
document.getElementById("pageInfo").textContent = from + "-" + to + " / " + total;
|
||||||
|
document.getElementById("prev").disabled = offset <= 0;
|
||||||
|
document.getElementById("next").disabled = offset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
const r = await fetch("api/records/" + id);
|
||||||
|
if (!r.ok) { alert("读取详情失败: " + await r.text()); return; }
|
||||||
|
const b = await r.json();
|
||||||
|
const hdr = h => Object.entries(h || {}).map(e =>
|
||||||
|
esc(e[0]) + ": " + esc(e[1])).join("\n");
|
||||||
|
document.getElementById("detail").innerHTML =
|
||||||
|
'<h2>记录 #' + b.id + '</h2>' +
|
||||||
|
'<div class="kv">' +
|
||||||
|
'<div class="k">时间</div><div>' + esc(b.timestamp) + '</div>' +
|
||||||
|
'<div class="k">客户端</div><div>' + esc(b.client_ip) + '</div>' +
|
||||||
|
'<div class="k">方法/路径</div><div>' + esc(b.method) + ' ' + esc(b.path) + '</div>' +
|
||||||
|
'<div class="k">状态码</div><div class="' + statusClass(b.status) + '">' + b.status + '</div>' +
|
||||||
|
'<div class="k">耗时</div><div>' + b.duration_ms + 'ms</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="sec-title">请求头</div><pre>' + esc(hdr(b.req_header)) + '</pre>' +
|
||||||
|
'<div class="sec-title">请求体</div><pre>' + esc(b.req_body) + '</pre>' +
|
||||||
|
'<div class="sec-title">响应头</div><pre>' + esc(hdr(b.resp_header)) + '</pre>' +
|
||||||
|
'<div class="sec-title">响应体</div><pre>' + esc(b.resp_body) + '</pre>';
|
||||||
|
document.getElementById("overlay").classList.add("show");
|
||||||
|
document.getElementById("drawer").classList.add("show");
|
||||||
|
}
|
||||||
|
function closeDrawer() {
|
||||||
|
document.getElementById("overlay").classList.remove("show");
|
||||||
|
document.getElementById("drawer").classList.remove("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() { offset = 0; loadRecords(); }
|
||||||
|
function clearFilter() {
|
||||||
|
document.getElementById("fModel").value = "";
|
||||||
|
document.getElementById("fFormat").value = "";
|
||||||
|
document.getElementById("fStatus").value = "";
|
||||||
|
offset = 0; loadRecords();
|
||||||
|
}
|
||||||
|
function prevPage() { offset = Math.max(0, offset - PAGE); loadRecords(); }
|
||||||
|
function nextPage() { if (offset + PAGE < total) { offset += PAGE; loadRecords(); } }
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
loadStats(); loadRecords();
|
||||||
|
document.getElementById("refreshInfo").textContent = "更新于 " + fmtTime(Date.now());
|
||||||
|
}
|
||||||
|
loadAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
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
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