diff --git a/README.md b/README.md new file mode 100644 index 0000000..b333635 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 0000000..a525bd0 --- /dev/null +++ b/proto/README.md @@ -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. 通知各语言使用方按上面的命令重新生成 diff --git a/proto/contract/v1/contract.proto b/proto/contract/v1/contract.proto new file mode 100644 index 0000000..c6b7ff6 --- /dev/null +++ b/proto/contract/v1/contract.proto @@ -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 req_header = 6; + bytes req_body = 7; + int32 status = 8; + map 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 req_header = 4; + bytes req_body = 5; + string client_ip = 6; +} +message HandleResponse { + int32 status = 1; + map 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; +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f0b78fa --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,27 @@ +# scripts — 构建与代码生成脚本 + +PowerShell 脚本,需在 Windows PowerShell 5.1 下运行。脚本内对 Go 工具链与 protoc 的路径做了硬编码,换机时按本机实际调整。 + +| 脚本 | 作用 | +|---|---| +| `build.ps1` | 全量构建:① 调 `genproto.ps1` 生成 proto 代码 → ② 编译内核入口 `cmd/relay` 到 `build/relay.exe` → ③ 逐个编译全部插件到 `build/plugins/.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 发现。 diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 2ab3ae8..57b4e24 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,12 +1,54 @@ -# 编译脚本(位于 scripts/):源码在 src/go/,产物输出到 build/relay.exe +# Build script (in scripts/): +# 1) generate proto code (calls genproto.ps1) +# 2) build kernel entry cmd/relay -> build/relay.exe +# 3) build all plugins -> build/plugins/.exe +# Source in src/go/, artifacts output to build/. $ErrorActionPreference = 'Stop' -$root = Split-Path $PSScriptRoot -Parent # 仓库根目录(scripts/ 的上一级) -$out = Join-Path $root 'build\relay.exe' +$root = Split-Path $PSScriptRoot -Parent # repo root (parent of scripts/) -Push-Location (Join-Path $root 'src\go') +$goExe = 'D:\RJ\Basic\GO\bin\go.exe' +$gopath = 'C:\Users\Administrator\go' +$env:GOPATH = $gopath +$env:PATH = "$gopath\bin;$env:PATH" + +$srcGo = Join-Path $root 'src\go' +$relayOut = Join-Path $root 'build\relay.exe' +$pluginOut = Join-Path $root 'build\plugins' + +# 1) generate proto code +& (Join-Path $PSScriptRoot 'genproto.ps1') + +# 2) build kernel entry +Push-Location $srcGo try { - go build -o $out . - Write-Host "Built: $out" + & $goExe build -o $relayOut ./cmd/relay + if ($LASTEXITCODE -ne 0) { throw "build relay failed (exit $LASTEXITCODE)" } + Write-Host "Built: $relayOut" } finally { Pop-Location } + +# 3) build all plugins (output name matches manifest plugin name) +New-Item -ItemType Directory -Force $pluginOut | Out-Null +$plugins = @( + @{ dir = 'echo'; out = 'echo-base.exe' }, + @{ dir = 'transform'; out = 'transform-mid.exe' }, + @{ dir = 'streaming'; out = 'streaming-demo.exe' }, + @{ dir = 'recorder'; out = 'recorder-base.exe' }, + @{ dir = 'usage'; out = 'usage-base.exe' }, + @{ dir = 'proxy'; out = 'proxy.exe' }, + @{ dir = 'dashboard'; out = 'dashboard.exe' } +) +Push-Location $srcGo +try { + foreach ($p in $plugins) { + $target = Join-Path $pluginOut $p.out + & $goExe build -o $target ('./plugins/' + $p.dir) + if ($LASTEXITCODE -ne 0) { throw ("build plugin " + $p.dir + " failed (exit $LASTEXITCODE)") } + Write-Host "Built plugin: $target" + } +} finally { + Pop-Location +} + +Write-Host "All artifacts built under build/." diff --git a/scripts/genproto.ps1 b/scripts/genproto.ps1 new file mode 100644 index 0000000..63188ff --- /dev/null +++ b/scripts/genproto.ps1 @@ -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)" diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..54d4d0a --- /dev/null +++ b/src/README.md @@ -0,0 +1,9 @@ +# src — 源码 + +项目的全部源码。目前只有一个 Go 模块,位于 `go/`。 + +| 子目录 | 说明 | +|---|---| +| `go/` | Go 模块 `goaiapi`:内核、插件 SDK、契约、全部插件源码 | + +模块布局、构建方式与各包职责见 `go/README.md`。 diff --git a/src/go/README.md b/src/go/README.md new file mode 100644 index 0000000..09ba441 --- /dev/null +++ b/src/go/README.md @@ -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`。 diff --git a/src/go/cmd/relay/main.go b/src/go/cmd/relay/main.go new file mode 100644 index 0000000..a254fe1 --- /dev/null +++ b/src/go/cmd/relay/main.go @@ -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 diff --git a/src/go/entry.go b/src/go/entry.go deleted file mode 100644 index 15d4c30..0000000 --- a/src/go/entry.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "context" - "time" -) - -// Entry 一次完整的请求-响应留存记录。 -type Entry struct { - ID int64 - Timestamp time.Time - ClientIP string - - Method string - Path string - - ReqHeader map[string]string - ReqBody []byte - - Status int - RespHeader map[string]string - RespBody []byte - DurationMs int64 - - // 解析出的用量信息 - APIFormat string // openai | claude | ollama | unknown - Model string - InputTokens int - OutputTokens int - TotalTokens int - IsStream bool - - // 原文落盘后的相对路径 - BlobPath string -} - -// entryCtxKey 用于在 request context 里传递 Entry,避免污染全局。 -type entryCtxKey struct{} - -func withEntry(ctx context.Context, e *Entry) context.Context { - return context.WithValue(ctx, entryCtxKey{}, e) -} - -func entryFrom(ctx context.Context) *Entry { - e, _ := ctx.Value(entryCtxKey{}).(*Entry) - return e -} diff --git a/src/go/go.mod b/src/go/go.mod index 0c5f08f..36849e5 100644 --- a/src/go/go.mod +++ b/src/go/go.mod @@ -2,15 +2,30 @@ module goaiapi go 1.26.3 -require modernc.org/sqlite v1.51.0 +require ( + github.com/Masterminds/semver/v3 v3.5.0 + github.com/hashicorp/go-plugin v1.8.0 + google.golang.org/grpc v1.81.1 + modernc.org/sqlite v1.51.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/src/go/go.sum b/src/go/go.sum index 0705615..ef0fa53 100644 --- a/src/go/go.sum +++ b/src/go/go.sum @@ -1,26 +1,97 @@ +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/src/go/internal/kernel/broker.go b/src/go/internal/kernel/broker.go new file mode 100644 index 0000000..44da51d --- /dev/null +++ b/src/go/internal/kernel/broker.go @@ -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 +} diff --git a/src/go/internal/kernel/control.go b/src/go/internal/kernel/control.go new file mode 100644 index 0000000..9227018 --- /dev/null +++ b/src/go/internal/kernel/control.go @@ -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) +} diff --git a/src/go/internal/kernel/integration_test.go b/src/go/internal/kernel/integration_test.go new file mode 100644 index 0000000..da1115c --- /dev/null +++ b/src/go/internal/kernel/integration_test.go @@ -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 diff --git a/src/go/internal/kernel/launcher.go b/src/go/internal/kernel/launcher.go new file mode 100644 index 0000000..2022727 --- /dev/null +++ b/src/go/internal/kernel/launcher.go @@ -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 +} diff --git a/src/go/internal/kernel/main_test.go b/src/go/internal/kernel/main_test.go new file mode 100644 index 0000000..e6c0fdd --- /dev/null +++ b/src/go/internal/kernel/main_test.go @@ -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() +} diff --git a/src/go/internal/kernel/registry.go b/src/go/internal/kernel/registry.go new file mode 100644 index 0000000..8a54cc3 --- /dev/null +++ b/src/go/internal/kernel/registry.go @@ -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, + } +} diff --git a/src/go/internal/kernel/registry_test.go b/src/go/internal/kernel/registry_test.go new file mode 100644 index 0000000..c6d6d93 --- /dev/null +++ b/src/go/internal/kernel/registry_test.go @@ -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() +} diff --git a/src/go/internal/kernel/resolver.go b/src/go/internal/kernel/resolver.go new file mode 100644 index 0000000..f9ad385 --- /dev/null +++ b/src/go/internal/kernel/resolver.go @@ -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) +} diff --git a/src/go/internal/kernel/resolver_test.go b/src/go/internal/kernel/resolver_test.go new file mode 100644 index 0000000..a042ce3 --- /dev/null +++ b/src/go/internal/kernel/resolver_test.go @@ -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) + } +} diff --git a/src/go/internal/kernel/types.go b/src/go/internal/kernel/types.go new file mode 100644 index 0000000..52069b0 --- /dev/null +++ b/src/go/internal/kernel/types.go @@ -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), + } +} diff --git a/src/go/main.go b/src/go/main.go deleted file mode 100644 index 996010f..0000000 --- a/src/go/main.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -import ( - "bytes" - "flag" - "io" - "log" - "net/http" - "net/http/httputil" - "net/url" - "strings" - "sync/atomic" - "time" -) - -// 命令行参数:本地监听地址 + 上游地址 + 数据目录 -var ( - listenAddr = flag.String("l", "127.0.0.1:8080", "本地监听地址 host:port") - upstream = flag.String("u", "https://api.ai.pulsareon.com", "上游地址 scheme://host[:port]") - dataDir = flag.String("d", "./data", "留存数据目录") - webAddr = flag.String("a", "127.0.0.1:8081", "Web 管理界面地址 host:port(空字符串关闭)") -) - -var connID atomic.Int64 - -func main() { - flag.Parse() - - target, err := url.Parse(*upstream) - if err != nil { - log.Fatalf("上游地址解析失败 %s: %v", *upstream, err) - } - - rec, err := NewRecorder(*dataDir) - if err != nil { - log.Fatalf("初始化留存模块失败: %v", err) - } - defer rec.Close() - - // Web 管理界面(独立端口,只读)。空地址则关闭。 - if *webAddr != "" { - go startWebUI(*webAddr, rec) - } - - proxy := &httputil.ReverseProxy{ - // Director 改写出站请求:指向上游,保持原路径 - Director: func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.Host = target.Host // 让上游看到正确的 Host 头 - }, - // ModifyResponse 在响应返回客户端前介入,做全量留存 - ModifyResponse: recordResponse(rec), - ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) { - log.Printf("[!] 转发失败 %s %s: %v", r.Method, r.URL.Path, e) - http.Error(w, "proxy error: "+e.Error(), http.StatusBadGateway) - }, - } - - // 外层 handler:抓请求原文 + 计时,再交给 proxy - handler := func(w http.ResponseWriter, r *http.Request) { - // seq 仅是本进程内的日志序号,方便把同一次请求的进/出两条日志对上; - // 它不是持久化主键——真正的主键由 SQLite AUTOINCREMENT 在 Save 时分配。 - seq := connID.Add(1) - start := time.Now() - - // 读取并缓存请求体(读完要还回去,否则 proxy 转发不到) - var reqBody []byte - if r.Body != nil { - reqBody, _ = io.ReadAll(r.Body) - r.Body = io.NopCloser(bytes.NewReader(reqBody)) - } - - // 把本次调用的上下文挂到 request 上,供 ModifyResponse 取用。 - // ID 留空,由 Recorder.Save 写库后回填权威主键。 - entry := &Entry{ - Timestamp: start, - ClientIP: clientIP(r), - Method: r.Method, - Path: r.URL.Path, - ReqHeader: flattenHeader(r.Header), - ReqBody: reqBody, - } - r = r.WithContext(withEntry(r.Context(), entry)) - - log.Printf("[+] #%d %s %s from %s", seq, r.Method, r.URL.Path, entry.ClientIP) - proxy.ServeHTTP(w, r) - } - - srv := &http.Server{ - Addr: *listenAddr, - Handler: http.HandlerFunc(handler), - } - log.Printf("反向代理启动: %s -> %s (数据目录 %s)", *listenAddr, *upstream, *dataDir) - if err := srv.ListenAndServe(); err != nil { - log.Fatalf("服务退出: %v", err) - } -} - -// recordResponse 返回一个 ModifyResponse 回调,做全量留存 + usage 解析。 -// -// 非流式:直接 io.ReadAll 读完响应体、解析 JSON usage、落库。 -// 流式(SSE):不能 io.ReadAll(会缓冲整条流,客户端要等所有 chunk 到齐才收到第一个字)。 -// 改用 streamRecorder 包住 resp.Body——数据被客户端读取时同步流过, -// 一边原样转发、一边拼回全量原文并解析 usage,读到 EOF 时回调落库。 -func recordResponse(rec *Recorder) func(*http.Response) error { - return func(resp *http.Response) error { - entry := entryFrom(resp.Request.Context()) - if entry == nil { - return nil // 没有上下文,跳过留存但不影响转发 - } - - entry.Status = resp.StatusCode - entry.RespHeader = flattenHeader(resp.Header) - entry.IsStream = isStream(resp) - - if entry.IsStream { - // 流式:包装 body,落库延迟到流结束(onDone)时进行。 - format := guessFormatByPath(entry.Path) - src := resp.Body - resp.Body = newStreamRecorder(src, format, func(raw []byte, in, out int, model string) { - entry.RespBody = raw - entry.DurationMs = time.Since(entry.Timestamp).Milliseconds() - entry.InputTokens = in - entry.OutputTokens = out - entry.TotalTokens = in + out - if model != "" { - entry.Model = model - } - // 流式响应体里没有顶层 usage 时,仍按路径标注格式 - entry.APIFormat = format - saveEntry(rec, entry) - }) - return nil - } - - // 非流式:读完响应体并还回去(客户端还要收) - respBody, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - log.Printf("[!] #%d 读取响应体失败: %v", entry.ID, err) - } - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - entry.RespBody = respBody - entry.DurationMs = time.Since(entry.Timestamp).Milliseconds() - parseUsage(entry, respBody) - saveEntry(rec, entry) - return nil - } -} - -// saveEntry 落库并打印一条结果日志,统一非流式/流式两条路径的收尾。 -func saveEntry(rec *Recorder, entry *Entry) { - if err := rec.Save(entry); err != nil { - log.Printf("[!] 留存失败: %v", err) - return - } - log.Printf("[-] #%d done status=%d %dms model=%s in=%d out=%d stream=%v", - entry.ID, entry.Status, entry.DurationMs, entry.Model, - entry.InputTokens, entry.OutputTokens, entry.IsStream) -} - -// isStream 判断响应是否为 SSE 流式。 -func isStream(resp *http.Response) bool { - ct := resp.Header.Get("Content-Type") - return strings.Contains(strings.ToLower(ct), "text/event-stream") -} - -// clientIP 取客户端 IP(去掉端口)。 -func clientIP(r *http.Request) string { - ip := r.RemoteAddr - if i := strings.LastIndex(ip, ":"); i >= 0 { - ip = ip[:i] - } - return ip -} - -// flattenHeader 把 http.Header 拍平成单层 map,便于 JSON 留存。 -func flattenHeader(h http.Header) map[string]string { - m := make(map[string]string, len(h)) - for k, v := range h { - m[k] = strings.Join(v, ", ") - } - return m -} diff --git a/src/go/plugins/dashboard/main.go b/src/go/plugins/dashboard/main.go new file mode 100644 index 0000000..9dfa3e7 --- /dev/null +++ b/src/go/plugins/dashboard/main.go @@ -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{}) }, + }, + }) +} diff --git a/src/go/webui.go b/src/go/plugins/dashboard/webui.go similarity index 99% rename from src/go/webui.go rename to src/go/plugins/dashboard/webui.go index e7da9c1..100efef 100644 --- a/src/go/webui.go +++ b/src/go/plugins/dashboard/webui.go @@ -2,6 +2,7 @@ package main // dashboardHTML 是只读管理界面的单页前端(原生 JS,无外部依赖)。 // 通过 /api/stats、/api/records、/api/records/{id} 拉数据。 +// 迁自原 webui.go,前端不变。 const dashboardHTML = ` diff --git a/src/go/plugins/echo/main.go b/src/go/plugins/echo/main.go new file mode 100644 index 0000000..31d5d5e --- /dev/null +++ b/src/go/plugins/echo/main.go @@ -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{}) }, + }, + }) +} diff --git a/src/go/plugins/proxy/main.go b/src/go/plugins/proxy/main.go new file mode 100644 index 0000000..f8944e9 --- /dev/null +++ b/src/go/plugins/proxy/main.go @@ -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) }, + }, + }) +} diff --git a/src/go/plugins/recorder/main.go b/src/go/plugins/recorder/main.go new file mode 100644 index 0000000..5373a5b --- /dev/null +++ b/src/go/plugins/recorder/main.go @@ -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) }, + }, + }) +} diff --git a/src/go/plugins/streaming/main.go b/src/go/plugins/streaming/main.go new file mode 100644 index 0000000..cdbe666 --- /dev/null +++ b/src/go/plugins/streaming/main.go @@ -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{}) }, + }, + }) +} diff --git a/src/go/plugins/transform/main.go b/src/go/plugins/transform/main.go new file mode 100644 index 0000000..48ebe0d --- /dev/null +++ b/src/go/plugins/transform/main.go @@ -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{}) }, + }, + }) +} diff --git a/src/go/plugins/usage/main.go b/src/go/plugins/usage/main.go new file mode 100644 index 0000000..76e64e0 --- /dev/null +++ b/src/go/plugins/usage/main.go @@ -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{}) }, + }, + }) +} diff --git a/src/go/pluginsdk/manifest.go b/src/go/pluginsdk/manifest.go new file mode 100644 index 0000000..9b3ac08 --- /dev/null +++ b/src/go/pluginsdk/manifest.go @@ -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, + } +} diff --git a/src/go/pluginsdk/marshal.go b/src/go/pluginsdk/marshal.go new file mode 100644 index 0000000..1028d1f --- /dev/null +++ b/src/go/pluginsdk/marshal.go @@ -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) +} diff --git a/src/go/pluginsdk/server.go b/src/go/pluginsdk/server.go new file mode 100644 index 0000000..5b830e8 --- /dev/null +++ b/src/go/pluginsdk/server.go @@ -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() } diff --git a/src/go/pluginsdk/types.go b/src/go/pluginsdk/types.go new file mode 100644 index 0000000..fcf6f32 --- /dev/null +++ b/src/go/pluginsdk/types.go @@ -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 名 +} diff --git a/src/go/proto/contract/v1/contract.pb.go b/src/go/proto/contract/v1/contract.pb.go new file mode 100644 index 0000000..ce3bfff --- /dev/null +++ b/src/go/proto/contract/v1/contract.pb.go @@ -0,0 +1,2505 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: contract/v1/contract.proto + +package contractv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PluginTier int32 + +const ( + PluginTier_TIER_UNSPECIFIED PluginTier = 0 + PluginTier_TIER_BASE PluginTier = 1 // 无插件依赖 + PluginTier_TIER_MID PluginTier = 2 // 仅依赖 base + PluginTier_TIER_COMPLEX PluginTier = 3 // 依赖至少一个非 base +) + +// Enum value maps for PluginTier. +var ( + PluginTier_name = map[int32]string{ + 0: "TIER_UNSPECIFIED", + 1: "TIER_BASE", + 2: "TIER_MID", + 3: "TIER_COMPLEX", + } + PluginTier_value = map[string]int32{ + "TIER_UNSPECIFIED": 0, + "TIER_BASE": 1, + "TIER_MID": 2, + "TIER_COMPLEX": 3, + } +) + +func (x PluginTier) Enum() *PluginTier { + p := new(PluginTier) + *p = x + return p +} + +func (x PluginTier) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PluginTier) Descriptor() protoreflect.EnumDescriptor { + return file_contract_v1_contract_proto_enumTypes[0].Descriptor() +} + +func (PluginTier) Type() protoreflect.EnumType { + return &file_contract_v1_contract_proto_enumTypes[0] +} + +func (x PluginTier) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PluginTier.Descriptor instead. +func (PluginTier) EnumDescriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{0} +} + +type GetManifestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetManifestRequest) Reset() { + *x = GetManifestRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetManifestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetManifestRequest) ProtoMessage() {} + +func (x *GetManifestRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetManifestRequest.ProtoReflect.Descriptor instead. +func (*GetManifestRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{0} +} + +type GetManifestResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // 插件唯一名,如 "echo-base" + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` // semver,如 "1.0.0" + Tier PluginTier `protobuf:"varint,3,opt,name=tier,proto3,enum=contract.v1.PluginTier" json:"tier,omitempty"` // 声明的层级(内核会用 DAG 复算校验) + Deps []*Dependency `protobuf:"bytes,4,rep,name=deps,proto3" json:"deps,omitempty"` + Caps []*Capability `protobuf:"bytes,5,rep,name=caps,proto3" json:"caps,omitempty"` // 本插件提供的 gRPC service 名 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetManifestResponse) Reset() { + *x = GetManifestResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetManifestResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetManifestResponse) ProtoMessage() {} + +func (x *GetManifestResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetManifestResponse.ProtoReflect.Descriptor instead. +func (*GetManifestResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{1} +} + +func (x *GetManifestResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetManifestResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *GetManifestResponse) GetTier() PluginTier { + if x != nil { + return x.Tier + } + return PluginTier_TIER_UNSPECIFIED +} + +func (x *GetManifestResponse) GetDeps() []*Dependency { + if x != nil { + return x.Deps + } + return nil +} + +func (x *GetManifestResponse) GetCaps() []*Capability { + if x != nil { + return x.Caps + } + return nil +} + +type Dependency struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // 依赖的插件名 + VersionConstraint string `protobuf:"bytes,2,opt,name=version_constraint,json=versionConstraint,proto3" json:"version_constraint,omitempty"` // semver 约束,如 ">=1.0.0 <2.0.0" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Dependency) Reset() { + *x = Dependency{} + mi := &file_contract_v1_contract_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Dependency) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Dependency) ProtoMessage() {} + +func (x *Dependency) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Dependency.ProtoReflect.Descriptor instead. +func (*Dependency) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{2} +} + +func (x *Dependency) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Dependency) GetVersionConstraint() string { + if x != nil { + return x.VersionConstraint + } + return "" +} + +type Capability struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // 如 "EchoService" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Capability) Reset() { + *x = Capability{} + mi := &file_contract_v1_contract_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Capability) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Capability) ProtoMessage() {} + +func (x *Capability) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Capability.ProtoReflect.Descriptor instead. +func (*Capability) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{3} +} + +func (x *Capability) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +type HealthCheckRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthCheckRequest) Reset() { + *x = HealthCheckRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthCheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthCheckRequest) ProtoMessage() {} + +func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead. +func (*HealthCheckRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{4} +} + +type HealthCheckResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthCheckResponse) Reset() { + *x = HealthCheckResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthCheckResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthCheckResponse) ProtoMessage() {} + +func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead. +func (*HealthCheckResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{5} +} + +func (x *HealthCheckResponse) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *HealthCheckResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type InvokeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TargetPlugin string `protobuf:"bytes,1,opt,name=target_plugin,json=targetPlugin,proto3" json:"target_plugin,omitempty"` // 目标插件名 + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` // 目标方法全路径,如 "/contract.v1.EchoService/Echo" + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` // 已序列化的请求(proto wire 字节) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeRequest) Reset() { + *x = InvokeRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeRequest) ProtoMessage() {} + +func (x *InvokeRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InvokeRequest.ProtoReflect.Descriptor instead. +func (*InvokeRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{6} +} + +func (x *InvokeRequest) GetTargetPlugin() string { + if x != nil { + return x.TargetPlugin + } + return "" +} + +func (x *InvokeRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *InvokeRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +type InvokeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result []byte `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` // 已序列化的响应 + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeResponse) Reset() { + *x = InvokeResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeResponse) ProtoMessage() {} + +func (x *InvokeResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InvokeResponse.ProtoReflect.Descriptor instead. +func (*InvokeResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{7} +} + +func (x *InvokeResponse) GetResult() []byte { + if x != nil { + return x.Result + } + return nil +} + +func (x *InvokeResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ListPluginsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPluginsRequest) Reset() { + *x = ListPluginsRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPluginsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPluginsRequest) ProtoMessage() {} + +func (x *ListPluginsRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPluginsRequest.ProtoReflect.Descriptor instead. +func (*ListPluginsRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{8} +} + +type ListPluginsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Plugins []*PluginInfo `protobuf:"bytes,1,rep,name=plugins,proto3" json:"plugins,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPluginsResponse) Reset() { + *x = ListPluginsResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPluginsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPluginsResponse) ProtoMessage() {} + +func (x *ListPluginsResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPluginsResponse.ProtoReflect.Descriptor instead. +func (*ListPluginsResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{9} +} + +func (x *ListPluginsResponse) GetPlugins() []*PluginInfo { + if x != nil { + return x.Plugins + } + return nil +} + +type PluginInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Tier PluginTier `protobuf:"varint,3,opt,name=tier,proto3,enum=contract.v1.PluginTier" json:"tier,omitempty"` + Healthy bool `protobuf:"varint,4,opt,name=healthy,proto3" json:"healthy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PluginInfo) Reset() { + *x = PluginInfo{} + mi := &file_contract_v1_contract_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PluginInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PluginInfo) ProtoMessage() {} + +func (x *PluginInfo) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PluginInfo.ProtoReflect.Descriptor instead. +func (*PluginInfo) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{10} +} + +func (x *PluginInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PluginInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *PluginInfo) GetTier() PluginTier { + if x != nil { + return x.Tier + } + return PluginTier_TIER_UNSPECIFIED +} + +func (x *PluginInfo) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +type EchoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchoRequest) Reset() { + *x = EchoRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchoRequest) ProtoMessage() {} + +func (x *EchoRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchoRequest.ProtoReflect.Descriptor instead. +func (*EchoRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{11} +} + +func (x *EchoRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type EchoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + EchoedBy string `protobuf:"bytes,2,opt,name=echoed_by,json=echoedBy,proto3" json:"echoed_by,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchoResponse) Reset() { + *x = EchoResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchoResponse) ProtoMessage() {} + +func (x *EchoResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchoResponse.ProtoReflect.Descriptor instead. +func (*EchoResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{12} +} + +func (x *EchoResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *EchoResponse) GetEchoedBy() string { + if x != nil { + return x.EchoedBy + } + return "" +} + +type TransformRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + Operation string `protobuf:"bytes,2,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransformRequest) Reset() { + *x = TransformRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransformRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransformRequest) ProtoMessage() {} + +func (x *TransformRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransformRequest.ProtoReflect.Descriptor instead. +func (*TransformRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{13} +} + +func (x *TransformRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *TransformRequest) GetOperation() string { + if x != nil { + return x.Operation + } + return "" +} + +type TransformResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result string `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + ViaEcho string `protobuf:"bytes,2,opt,name=via_echo,json=viaEcho,proto3" json:"via_echo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransformResponse) Reset() { + *x = TransformResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransformResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransformResponse) ProtoMessage() {} + +func (x *TransformResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransformResponse.ProtoReflect.Descriptor instead. +func (*TransformResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{14} +} + +func (x *TransformResponse) GetResult() string { + if x != nil { + return x.Result + } + return "" +} + +func (x *TransformResponse) GetViaEcho() string { + if x != nil { + return x.ViaEcho + } + return "" +} + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{15} +} + +func (x *PingRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Pipeline string `protobuf:"bytes,2,opt,name=pipeline,proto3" json:"pipeline,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{16} +} + +func (x *PingResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *PingResponse) GetPipeline() string { + if x != nil { + return x.Pipeline + } + return "" +} + +type StreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Count int32 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + IntervalMs int32 `protobuf:"varint,2,opt,name=interval_ms,json=intervalMs,proto3" json:"interval_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamRequest) Reset() { + *x = StreamRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamRequest) ProtoMessage() {} + +func (x *StreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamRequest.ProtoReflect.Descriptor instead. +func (*StreamRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{17} +} + +func (x *StreamRequest) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *StreamRequest) GetIntervalMs() int32 { + if x != nil { + return x.IntervalMs + } + return 0 +} + +type StreamEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sequence int32 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"` + Payload string `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + TimestampNs int64 `protobuf:"varint,3,opt,name=timestamp_ns,json=timestampNs,proto3" json:"timestamp_ns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent) Reset() { + *x = StreamEvent{} + mi := &file_contract_v1_contract_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent) ProtoMessage() {} + +func (x *StreamEvent) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamEvent.ProtoReflect.Descriptor instead. +func (*StreamEvent) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{18} +} + +func (x *StreamEvent) GetSequence() int32 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *StreamEvent) GetPayload() string { + if x != nil { + return x.Payload + } + return "" +} + +func (x *StreamEvent) GetTimestampNs() int64 { + if x != nil { + return x.TimestampNs + } + return 0 +} + +// 一条请求-响应留存记录的可序列化表示(跨插件传递用)。 +type TrafficEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + TimestampMs int64 `protobuf:"varint,2,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + ClientIp string `protobuf:"bytes,3,opt,name=client_ip,json=clientIp,proto3" json:"client_ip,omitempty"` + Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,5,opt,name=path,proto3" json:"path,omitempty"` + ReqHeader map[string]string `protobuf:"bytes,6,rep,name=req_header,json=reqHeader,proto3" json:"req_header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ReqBody []byte `protobuf:"bytes,7,opt,name=req_body,json=reqBody,proto3" json:"req_body,omitempty"` + Status int32 `protobuf:"varint,8,opt,name=status,proto3" json:"status,omitempty"` + RespHeader map[string]string `protobuf:"bytes,9,rep,name=resp_header,json=respHeader,proto3" json:"resp_header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + RespBody []byte `protobuf:"bytes,10,opt,name=resp_body,json=respBody,proto3" json:"resp_body,omitempty"` + DurationMs int64 `protobuf:"varint,11,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + ApiFormat string `protobuf:"bytes,12,opt,name=api_format,json=apiFormat,proto3" json:"api_format,omitempty"` // openai | claude | ollama | unknown + Model string `protobuf:"bytes,13,opt,name=model,proto3" json:"model,omitempty"` + InputTokens int32 `protobuf:"varint,14,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens int32 `protobuf:"varint,15,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens int32 `protobuf:"varint,16,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + IsStream bool `protobuf:"varint,17,opt,name=is_stream,json=isStream,proto3" json:"is_stream,omitempty"` + BlobPath string `protobuf:"bytes,18,opt,name=blob_path,json=blobPath,proto3" json:"blob_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TrafficEntry) Reset() { + *x = TrafficEntry{} + mi := &file_contract_v1_contract_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TrafficEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TrafficEntry) ProtoMessage() {} + +func (x *TrafficEntry) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TrafficEntry.ProtoReflect.Descriptor instead. +func (*TrafficEntry) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{19} +} + +func (x *TrafficEntry) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *TrafficEntry) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *TrafficEntry) GetClientIp() string { + if x != nil { + return x.ClientIp + } + return "" +} + +func (x *TrafficEntry) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *TrafficEntry) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *TrafficEntry) GetReqHeader() map[string]string { + if x != nil { + return x.ReqHeader + } + return nil +} + +func (x *TrafficEntry) GetReqBody() []byte { + if x != nil { + return x.ReqBody + } + return nil +} + +func (x *TrafficEntry) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *TrafficEntry) GetRespHeader() map[string]string { + if x != nil { + return x.RespHeader + } + return nil +} + +func (x *TrafficEntry) GetRespBody() []byte { + if x != nil { + return x.RespBody + } + return nil +} + +func (x *TrafficEntry) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +func (x *TrafficEntry) GetApiFormat() string { + if x != nil { + return x.ApiFormat + } + return "" +} + +func (x *TrafficEntry) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *TrafficEntry) GetInputTokens() int32 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *TrafficEntry) GetOutputTokens() int32 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *TrafficEntry) GetTotalTokens() int32 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *TrafficEntry) GetIsStream() bool { + if x != nil { + return x.IsStream + } + return false +} + +func (x *TrafficEntry) GetBlobPath() string { + if x != nil { + return x.BlobPath + } + return "" +} + +// UsageInfo:从响应体解析出的用量信息。 +type UsageInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + ApiFormat string `protobuf:"bytes,1,opt,name=api_format,json=apiFormat,proto3" json:"api_format,omitempty"` + Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` + InputTokens int32 `protobuf:"varint,3,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens int32 `protobuf:"varint,4,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens int32 `protobuf:"varint,5,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UsageInfo) Reset() { + *x = UsageInfo{} + mi := &file_contract_v1_contract_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UsageInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UsageInfo) ProtoMessage() {} + +func (x *UsageInfo) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UsageInfo.ProtoReflect.Descriptor instead. +func (*UsageInfo) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{20} +} + +func (x *UsageInfo) GetApiFormat() string { + if x != nil { + return x.ApiFormat + } + return "" +} + +func (x *UsageInfo) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *UsageInfo) GetInputTokens() int32 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *UsageInfo) GetOutputTokens() int32 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *UsageInfo) GetTotalTokens() int32 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +type SaveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *TrafficEntry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveRequest) Reset() { + *x = SaveRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveRequest) ProtoMessage() {} + +func (x *SaveRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SaveRequest.ProtoReflect.Descriptor instead. +func (*SaveRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{21} +} + +func (x *SaveRequest) GetEntry() *TrafficEntry { + if x != nil { + return x.Entry + } + return nil +} + +type SaveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveResponse) Reset() { + *x = SaveResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveResponse) ProtoMessage() {} + +func (x *SaveResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SaveResponse.ProtoReflect.Descriptor instead. +func (*SaveResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{22} +} + +func (x *SaveResponse) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *SaveResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + Offset int32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` + Format string `protobuf:"bytes,4,opt,name=format,proto3" json:"format,omitempty"` + Status int32 `protobuf:"varint,5,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{23} +} + +func (x *ListRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListRequest) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *ListRequest) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +func (x *ListRequest) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Records []*TrafficEntry `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + Total int32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{24} +} + +func (x *ListResponse) GetRecords() []*TrafficEntry { + if x != nil { + return x.Records + } + return nil +} + +func (x *ListResponse) GetTotal() int32 { + if x != nil { + return x.Total + } + return 0 +} + +type ReadBlobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadBlobRequest) Reset() { + *x = ReadBlobRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadBlobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadBlobRequest) ProtoMessage() {} + +func (x *ReadBlobRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadBlobRequest.ProtoReflect.Descriptor instead. +func (*ReadBlobRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{25} +} + +func (x *ReadBlobRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type ReadBlobResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *TrafficEntry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadBlobResponse) Reset() { + *x = ReadBlobResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadBlobResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadBlobResponse) ProtoMessage() {} + +func (x *ReadBlobResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadBlobResponse.ProtoReflect.Descriptor instead. +func (*ReadBlobResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{26} +} + +func (x *ReadBlobResponse) GetEntry() *TrafficEntry { + if x != nil { + return x.Entry + } + return nil +} + +func (x *ReadBlobResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type StatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatsRequest) Reset() { + *x = StatsRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatsRequest) ProtoMessage() {} + +func (x *StatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatsRequest.ProtoReflect.Descriptor instead. +func (*StatsRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{27} +} + +type ModelStat struct { + state protoimpl.MessageState `protogen:"open.v1"` + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` + Count int32 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + InputTokens int64 `protobuf:"varint,3,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens int64 `protobuf:"varint,4,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens int64 `protobuf:"varint,5,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelStat) Reset() { + *x = ModelStat{} + mi := &file_contract_v1_contract_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelStat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelStat) ProtoMessage() {} + +func (x *ModelStat) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModelStat.ProtoReflect.Descriptor instead. +func (*ModelStat) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{28} +} + +func (x *ModelStat) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *ModelStat) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *ModelStat) GetInputTokens() int64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *ModelStat) GetOutputTokens() int64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *ModelStat) GetTotalTokens() int64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +type StatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalRequests int32 `protobuf:"varint,1,opt,name=total_requests,json=totalRequests,proto3" json:"total_requests,omitempty"` + SuccessCount int32 `protobuf:"varint,2,opt,name=success_count,json=successCount,proto3" json:"success_count,omitempty"` + ErrorCount int32 `protobuf:"varint,3,opt,name=error_count,json=errorCount,proto3" json:"error_count,omitempty"` + TotalTokens int64 `protobuf:"varint,4,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + ByModel []*ModelStat `protobuf:"bytes,5,rep,name=by_model,json=byModel,proto3" json:"by_model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatsResponse) Reset() { + *x = StatsResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatsResponse) ProtoMessage() {} + +func (x *StatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatsResponse.ProtoReflect.Descriptor instead. +func (*StatsResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{29} +} + +func (x *StatsResponse) GetTotalRequests() int32 { + if x != nil { + return x.TotalRequests + } + return 0 +} + +func (x *StatsResponse) GetSuccessCount() int32 { + if x != nil { + return x.SuccessCount + } + return 0 +} + +func (x *StatsResponse) GetErrorCount() int32 { + if x != nil { + return x.ErrorCount + } + return 0 +} + +func (x *StatsResponse) GetTotalTokens() int64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *StatsResponse) GetByModel() []*ModelStat { + if x != nil { + return x.ByModel + } + return nil +} + +type ParseBodyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ParseBodyRequest) Reset() { + *x = ParseBodyRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ParseBodyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ParseBodyRequest) ProtoMessage() {} + +func (x *ParseBodyRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ParseBodyRequest.ProtoReflect.Descriptor instead. +func (*ParseBodyRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{30} +} + +func (x *ParseBodyRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *ParseBodyRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ParseStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Raw []byte `protobuf:"bytes,1,opt,name=raw,proto3" json:"raw,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ParseStreamRequest) Reset() { + *x = ParseStreamRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ParseStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ParseStreamRequest) ProtoMessage() {} + +func (x *ParseStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ParseStreamRequest.ProtoReflect.Descriptor instead. +func (*ParseStreamRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{31} +} + +func (x *ParseStreamRequest) GetRaw() []byte { + if x != nil { + return x.Raw + } + return nil +} + +func (x *ParseStreamRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type HandleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + ReqHeader map[string]string `protobuf:"bytes,4,rep,name=req_header,json=reqHeader,proto3" json:"req_header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ReqBody []byte `protobuf:"bytes,5,opt,name=req_body,json=reqBody,proto3" json:"req_body,omitempty"` + ClientIp string `protobuf:"bytes,6,opt,name=client_ip,json=clientIp,proto3" json:"client_ip,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HandleRequest) Reset() { + *x = HandleRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HandleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HandleRequest) ProtoMessage() {} + +func (x *HandleRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HandleRequest.ProtoReflect.Descriptor instead. +func (*HandleRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{32} +} + +func (x *HandleRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *HandleRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *HandleRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *HandleRequest) GetReqHeader() map[string]string { + if x != nil { + return x.ReqHeader + } + return nil +} + +func (x *HandleRequest) GetReqBody() []byte { + if x != nil { + return x.ReqBody + } + return nil +} + +func (x *HandleRequest) GetClientIp() string { + if x != nil { + return x.ClientIp + } + return "" +} + +type HandleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + RespHeader map[string]string `protobuf:"bytes,2,rep,name=resp_header,json=respHeader,proto3" json:"resp_header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + RespBody []byte `protobuf:"bytes,3,opt,name=resp_body,json=respBody,proto3" json:"resp_body,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HandleResponse) Reset() { + *x = HandleResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HandleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HandleResponse) ProtoMessage() {} + +func (x *HandleResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HandleResponse.ProtoReflect.Descriptor instead. +func (*HandleResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{33} +} + +func (x *HandleResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *HandleResponse) GetRespHeader() map[string]string { + if x != nil { + return x.RespHeader + } + return nil +} + +func (x *HandleResponse) GetRespBody() []byte { + if x != nil { + return x.RespBody + } + return nil +} + +func (x *HandleResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_contract_v1_contract_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{34} +} + +func (x *RenderRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *RenderRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + ContentType string `protobuf:"bytes,2,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_contract_v1_contract_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_contract_v1_contract_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_contract_v1_contract_proto_rawDescGZIP(), []int{35} +} + +func (x *RenderResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *RenderResponse) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *RenderResponse) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +var File_contract_v1_contract_proto protoreflect.FileDescriptor + +const file_contract_v1_contract_proto_rawDesc = "" + + "\n" + + "\x1acontract/v1/contract.proto\x12\vcontract.v1\"\x14\n" + + "\x12GetManifestRequest\"\xca\x01\n" + + "\x13GetManifestResponse\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12+\n" + + "\x04tier\x18\x03 \x01(\x0e2\x17.contract.v1.PluginTierR\x04tier\x12+\n" + + "\x04deps\x18\x04 \x03(\v2\x17.contract.v1.DependencyR\x04deps\x12+\n" + + "\x04caps\x18\x05 \x03(\v2\x17.contract.v1.CapabilityR\x04caps\"O\n" + + "\n" + + "Dependency\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12-\n" + + "\x12version_constraint\x18\x02 \x01(\tR\x11versionConstraint\"/\n" + + "\n" + + "Capability\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\"\x14\n" + + "\x12HealthCheckRequest\"I\n" + + "\x13HealthCheckResponse\x12\x18\n" + + "\ahealthy\x18\x01 \x01(\bR\ahealthy\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"f\n" + + "\rInvokeRequest\x12#\n" + + "\rtarget_plugin\x18\x01 \x01(\tR\ftargetPlugin\x12\x16\n" + + "\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" + + "\apayload\x18\x03 \x01(\fR\apayload\">\n" + + "\x0eInvokeResponse\x12\x16\n" + + "\x06result\x18\x01 \x01(\fR\x06result\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x14\n" + + "\x12ListPluginsRequest\"H\n" + + "\x13ListPluginsResponse\x121\n" + + "\aplugins\x18\x01 \x03(\v2\x17.contract.v1.PluginInfoR\aplugins\"\x81\x01\n" + + "\n" + + "PluginInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12+\n" + + "\x04tier\x18\x03 \x01(\x0e2\x17.contract.v1.PluginTierR\x04tier\x12\x18\n" + + "\ahealthy\x18\x04 \x01(\bR\ahealthy\"'\n" + + "\vEchoRequest\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"E\n" + + "\fEchoResponse\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" + + "\techoed_by\x18\x02 \x01(\tR\bechoedBy\"D\n" + + "\x10TransformRequest\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1c\n" + + "\toperation\x18\x02 \x01(\tR\toperation\"F\n" + + "\x11TransformResponse\x12\x16\n" + + "\x06result\x18\x01 \x01(\tR\x06result\x12\x19\n" + + "\bvia_echo\x18\x02 \x01(\tR\aviaEcho\"'\n" + + "\vPingRequest\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"D\n" + + "\fPingResponse\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x1a\n" + + "\bpipeline\x18\x02 \x01(\tR\bpipeline\"F\n" + + "\rStreamRequest\x12\x14\n" + + "\x05count\x18\x01 \x01(\x05R\x05count\x12\x1f\n" + + "\vinterval_ms\x18\x02 \x01(\x05R\n" + + "intervalMs\"f\n" + + "\vStreamEvent\x12\x1a\n" + + "\bsequence\x18\x01 \x01(\x05R\bsequence\x12\x18\n" + + "\apayload\x18\x02 \x01(\tR\apayload\x12!\n" + + "\ftimestamp_ns\x18\x03 \x01(\x03R\vtimestampNs\"\xe7\x05\n" + + "\fTrafficEntry\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12!\n" + + "\ftimestamp_ms\x18\x02 \x01(\x03R\vtimestampMs\x12\x1b\n" + + "\tclient_ip\x18\x03 \x01(\tR\bclientIp\x12\x16\n" + + "\x06method\x18\x04 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x05 \x01(\tR\x04path\x12G\n" + + "\n" + + "req_header\x18\x06 \x03(\v2(.contract.v1.TrafficEntry.ReqHeaderEntryR\treqHeader\x12\x19\n" + + "\breq_body\x18\a \x01(\fR\areqBody\x12\x16\n" + + "\x06status\x18\b \x01(\x05R\x06status\x12J\n" + + "\vresp_header\x18\t \x03(\v2).contract.v1.TrafficEntry.RespHeaderEntryR\n" + + "respHeader\x12\x1b\n" + + "\tresp_body\x18\n" + + " \x01(\fR\brespBody\x12\x1f\n" + + "\vduration_ms\x18\v \x01(\x03R\n" + + "durationMs\x12\x1d\n" + + "\n" + + "api_format\x18\f \x01(\tR\tapiFormat\x12\x14\n" + + "\x05model\x18\r \x01(\tR\x05model\x12!\n" + + "\finput_tokens\x18\x0e \x01(\x05R\vinputTokens\x12#\n" + + "\routput_tokens\x18\x0f \x01(\x05R\foutputTokens\x12!\n" + + "\ftotal_tokens\x18\x10 \x01(\x05R\vtotalTokens\x12\x1b\n" + + "\tis_stream\x18\x11 \x01(\bR\bisStream\x12\x1b\n" + + "\tblob_path\x18\x12 \x01(\tR\bblobPath\x1a<\n" + + "\x0eReqHeaderEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" + + "\x0fRespHeaderEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xab\x01\n" + + "\tUsageInfo\x12\x1d\n" + + "\n" + + "api_format\x18\x01 \x01(\tR\tapiFormat\x12\x14\n" + + "\x05model\x18\x02 \x01(\tR\x05model\x12!\n" + + "\finput_tokens\x18\x03 \x01(\x05R\vinputTokens\x12#\n" + + "\routput_tokens\x18\x04 \x01(\x05R\foutputTokens\x12!\n" + + "\ftotal_tokens\x18\x05 \x01(\x05R\vtotalTokens\">\n" + + "\vSaveRequest\x12/\n" + + "\x05entry\x18\x01 \x01(\v2\x19.contract.v1.TrafficEntryR\x05entry\"4\n" + + "\fSaveResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x81\x01\n" + + "\vListRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\x05R\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\x05R\x06offset\x12\x14\n" + + "\x05model\x18\x03 \x01(\tR\x05model\x12\x16\n" + + "\x06format\x18\x04 \x01(\tR\x06format\x12\x16\n" + + "\x06status\x18\x05 \x01(\x05R\x06status\"Y\n" + + "\fListResponse\x123\n" + + "\arecords\x18\x01 \x03(\v2\x19.contract.v1.TrafficEntryR\arecords\x12\x14\n" + + "\x05total\x18\x02 \x01(\x05R\x05total\"!\n" + + "\x0fReadBlobRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"Y\n" + + "\x10ReadBlobResponse\x12/\n" + + "\x05entry\x18\x01 \x01(\v2\x19.contract.v1.TrafficEntryR\x05entry\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x0e\n" + + "\fStatsRequest\"\xa2\x01\n" + + "\tModelStat\x12\x14\n" + + "\x05model\x18\x01 \x01(\tR\x05model\x12\x14\n" + + "\x05count\x18\x02 \x01(\x05R\x05count\x12!\n" + + "\finput_tokens\x18\x03 \x01(\x03R\vinputTokens\x12#\n" + + "\routput_tokens\x18\x04 \x01(\x03R\foutputTokens\x12!\n" + + "\ftotal_tokens\x18\x05 \x01(\x03R\vtotalTokens\"\xd2\x01\n" + + "\rStatsResponse\x12%\n" + + "\x0etotal_requests\x18\x01 \x01(\x05R\rtotalRequests\x12#\n" + + "\rsuccess_count\x18\x02 \x01(\x05R\fsuccessCount\x12\x1f\n" + + "\verror_count\x18\x03 \x01(\x05R\n" + + "errorCount\x12!\n" + + "\ftotal_tokens\x18\x04 \x01(\x03R\vtotalTokens\x121\n" + + "\bby_model\x18\x05 \x03(\v2\x16.contract.v1.ModelStatR\abyModel\":\n" + + "\x10ParseBodyRequest\x12\x12\n" + + "\x04body\x18\x01 \x01(\fR\x04body\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\":\n" + + "\x12ParseStreamRequest\x12\x10\n" + + "\x03raw\x18\x01 \x01(\fR\x03raw\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"\x91\x02\n" + + "\rHandleRequest\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x14\n" + + "\x05query\x18\x03 \x01(\tR\x05query\x12H\n" + + "\n" + + "req_header\x18\x04 \x03(\v2).contract.v1.HandleRequest.ReqHeaderEntryR\treqHeader\x12\x19\n" + + "\breq_body\x18\x05 \x01(\fR\areqBody\x12\x1b\n" + + "\tclient_ip\x18\x06 \x01(\tR\bclientIp\x1a<\n" + + "\x0eReqHeaderEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe8\x01\n" + + "\x0eHandleResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\x05R\x06status\x12L\n" + + "\vresp_header\x18\x02 \x03(\v2+.contract.v1.HandleResponse.RespHeaderEntryR\n" + + "respHeader\x12\x1b\n" + + "\tresp_body\x18\x03 \x01(\fR\brespBody\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x1a=\n" + + "\x0fRespHeaderEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" + + "\rRenderRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05query\x18\x02 \x01(\tR\x05query\"_\n" + + "\x0eRenderResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\x05R\x06status\x12!\n" + + "\fcontent_type\x18\x02 \x01(\tR\vcontentType\x12\x12\n" + + "\x04body\x18\x03 \x01(\fR\x04body*Q\n" + + "\n" + + "PluginTier\x12\x14\n" + + "\x10TIER_UNSPECIFIED\x10\x00\x12\r\n" + + "\tTIER_BASE\x10\x01\x12\f\n" + + "\bTIER_MID\x10\x02\x12\x10\n" + + "\fTIER_COMPLEX\x10\x032\xb4\x01\n" + + "\x0ePluginManifest\x12P\n" + + "\vGetManifest\x12\x1f.contract.v1.GetManifestRequest\x1a .contract.v1.GetManifestResponse\x12P\n" + + "\vHealthCheck\x12\x1f.contract.v1.HealthCheckRequest\x1a .contract.v1.HealthCheckResponse2\xa7\x01\n" + + "\x10InvocationBroker\x12A\n" + + "\x06Invoke\x12\x1a.contract.v1.InvokeRequest\x1a\x1b.contract.v1.InvokeResponse\x12P\n" + + "\vListPlugins\x12\x1f.contract.v1.ListPluginsRequest\x1a .contract.v1.ListPluginsResponse2J\n" + + "\vEchoService\x12;\n" + + "\x04Echo\x12\x18.contract.v1.EchoRequest\x1a\x19.contract.v1.EchoResponse2^\n" + + "\x10TransformService\x12J\n" + + "\tTransform\x12\x1d.contract.v1.TransformRequest\x1a\x1e.contract.v1.TransformResponse2\x99\x01\n" + + "\rStreamingDemo\x12@\n" + + "\tUnaryPing\x12\x18.contract.v1.PingRequest\x1a\x19.contract.v1.PingResponse\x12F\n" + + "\fStreamEvents\x12\x1a.contract.v1.StreamRequest\x1a\x18.contract.v1.StreamEvent0\x012\x94\x02\n" + + "\x0fRecorderService\x12;\n" + + "\x04Save\x12\x18.contract.v1.SaveRequest\x1a\x19.contract.v1.SaveResponse\x12;\n" + + "\x04List\x12\x18.contract.v1.ListRequest\x1a\x19.contract.v1.ListResponse\x12G\n" + + "\bReadBlob\x12\x1c.contract.v1.ReadBlobRequest\x1a\x1d.contract.v1.ReadBlobResponse\x12>\n" + + "\x05Stats\x12\x19.contract.v1.StatsRequest\x1a\x1a.contract.v1.StatsResponse2\x9a\x01\n" + + "\fUsageService\x12B\n" + + "\tParseBody\x12\x1d.contract.v1.ParseBodyRequest\x1a\x16.contract.v1.UsageInfo\x12F\n" + + "\vParseStream\x12\x1f.contract.v1.ParseStreamRequest\x1a\x16.contract.v1.UsageInfo2Q\n" + + "\fProxyService\x12A\n" + + "\x06Handle\x12\x1a.contract.v1.HandleRequest\x1a\x1b.contract.v1.HandleResponse2U\n" + + "\x10DashboardService\x12A\n" + + "\x06Render\x12\x1a.contract.v1.RenderRequest\x1a\x1b.contract.v1.RenderResponseB&Z$goaiapi/proto/contract/v1;contractv1b\x06proto3" + +var ( + file_contract_v1_contract_proto_rawDescOnce sync.Once + file_contract_v1_contract_proto_rawDescData []byte +) + +func file_contract_v1_contract_proto_rawDescGZIP() []byte { + file_contract_v1_contract_proto_rawDescOnce.Do(func() { + file_contract_v1_contract_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_contract_v1_contract_proto_rawDesc), len(file_contract_v1_contract_proto_rawDesc))) + }) + return file_contract_v1_contract_proto_rawDescData +} + +var file_contract_v1_contract_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_contract_v1_contract_proto_msgTypes = make([]protoimpl.MessageInfo, 40) +var file_contract_v1_contract_proto_goTypes = []any{ + (PluginTier)(0), // 0: contract.v1.PluginTier + (*GetManifestRequest)(nil), // 1: contract.v1.GetManifestRequest + (*GetManifestResponse)(nil), // 2: contract.v1.GetManifestResponse + (*Dependency)(nil), // 3: contract.v1.Dependency + (*Capability)(nil), // 4: contract.v1.Capability + (*HealthCheckRequest)(nil), // 5: contract.v1.HealthCheckRequest + (*HealthCheckResponse)(nil), // 6: contract.v1.HealthCheckResponse + (*InvokeRequest)(nil), // 7: contract.v1.InvokeRequest + (*InvokeResponse)(nil), // 8: contract.v1.InvokeResponse + (*ListPluginsRequest)(nil), // 9: contract.v1.ListPluginsRequest + (*ListPluginsResponse)(nil), // 10: contract.v1.ListPluginsResponse + (*PluginInfo)(nil), // 11: contract.v1.PluginInfo + (*EchoRequest)(nil), // 12: contract.v1.EchoRequest + (*EchoResponse)(nil), // 13: contract.v1.EchoResponse + (*TransformRequest)(nil), // 14: contract.v1.TransformRequest + (*TransformResponse)(nil), // 15: contract.v1.TransformResponse + (*PingRequest)(nil), // 16: contract.v1.PingRequest + (*PingResponse)(nil), // 17: contract.v1.PingResponse + (*StreamRequest)(nil), // 18: contract.v1.StreamRequest + (*StreamEvent)(nil), // 19: contract.v1.StreamEvent + (*TrafficEntry)(nil), // 20: contract.v1.TrafficEntry + (*UsageInfo)(nil), // 21: contract.v1.UsageInfo + (*SaveRequest)(nil), // 22: contract.v1.SaveRequest + (*SaveResponse)(nil), // 23: contract.v1.SaveResponse + (*ListRequest)(nil), // 24: contract.v1.ListRequest + (*ListResponse)(nil), // 25: contract.v1.ListResponse + (*ReadBlobRequest)(nil), // 26: contract.v1.ReadBlobRequest + (*ReadBlobResponse)(nil), // 27: contract.v1.ReadBlobResponse + (*StatsRequest)(nil), // 28: contract.v1.StatsRequest + (*ModelStat)(nil), // 29: contract.v1.ModelStat + (*StatsResponse)(nil), // 30: contract.v1.StatsResponse + (*ParseBodyRequest)(nil), // 31: contract.v1.ParseBodyRequest + (*ParseStreamRequest)(nil), // 32: contract.v1.ParseStreamRequest + (*HandleRequest)(nil), // 33: contract.v1.HandleRequest + (*HandleResponse)(nil), // 34: contract.v1.HandleResponse + (*RenderRequest)(nil), // 35: contract.v1.RenderRequest + (*RenderResponse)(nil), // 36: contract.v1.RenderResponse + nil, // 37: contract.v1.TrafficEntry.ReqHeaderEntry + nil, // 38: contract.v1.TrafficEntry.RespHeaderEntry + nil, // 39: contract.v1.HandleRequest.ReqHeaderEntry + nil, // 40: contract.v1.HandleResponse.RespHeaderEntry +} +var file_contract_v1_contract_proto_depIdxs = []int32{ + 0, // 0: contract.v1.GetManifestResponse.tier:type_name -> contract.v1.PluginTier + 3, // 1: contract.v1.GetManifestResponse.deps:type_name -> contract.v1.Dependency + 4, // 2: contract.v1.GetManifestResponse.caps:type_name -> contract.v1.Capability + 11, // 3: contract.v1.ListPluginsResponse.plugins:type_name -> contract.v1.PluginInfo + 0, // 4: contract.v1.PluginInfo.tier:type_name -> contract.v1.PluginTier + 37, // 5: contract.v1.TrafficEntry.req_header:type_name -> contract.v1.TrafficEntry.ReqHeaderEntry + 38, // 6: contract.v1.TrafficEntry.resp_header:type_name -> contract.v1.TrafficEntry.RespHeaderEntry + 20, // 7: contract.v1.SaveRequest.entry:type_name -> contract.v1.TrafficEntry + 20, // 8: contract.v1.ListResponse.records:type_name -> contract.v1.TrafficEntry + 20, // 9: contract.v1.ReadBlobResponse.entry:type_name -> contract.v1.TrafficEntry + 29, // 10: contract.v1.StatsResponse.by_model:type_name -> contract.v1.ModelStat + 39, // 11: contract.v1.HandleRequest.req_header:type_name -> contract.v1.HandleRequest.ReqHeaderEntry + 40, // 12: contract.v1.HandleResponse.resp_header:type_name -> contract.v1.HandleResponse.RespHeaderEntry + 1, // 13: contract.v1.PluginManifest.GetManifest:input_type -> contract.v1.GetManifestRequest + 5, // 14: contract.v1.PluginManifest.HealthCheck:input_type -> contract.v1.HealthCheckRequest + 7, // 15: contract.v1.InvocationBroker.Invoke:input_type -> contract.v1.InvokeRequest + 9, // 16: contract.v1.InvocationBroker.ListPlugins:input_type -> contract.v1.ListPluginsRequest + 12, // 17: contract.v1.EchoService.Echo:input_type -> contract.v1.EchoRequest + 14, // 18: contract.v1.TransformService.Transform:input_type -> contract.v1.TransformRequest + 16, // 19: contract.v1.StreamingDemo.UnaryPing:input_type -> contract.v1.PingRequest + 18, // 20: contract.v1.StreamingDemo.StreamEvents:input_type -> contract.v1.StreamRequest + 22, // 21: contract.v1.RecorderService.Save:input_type -> contract.v1.SaveRequest + 24, // 22: contract.v1.RecorderService.List:input_type -> contract.v1.ListRequest + 26, // 23: contract.v1.RecorderService.ReadBlob:input_type -> contract.v1.ReadBlobRequest + 28, // 24: contract.v1.RecorderService.Stats:input_type -> contract.v1.StatsRequest + 31, // 25: contract.v1.UsageService.ParseBody:input_type -> contract.v1.ParseBodyRequest + 32, // 26: contract.v1.UsageService.ParseStream:input_type -> contract.v1.ParseStreamRequest + 33, // 27: contract.v1.ProxyService.Handle:input_type -> contract.v1.HandleRequest + 35, // 28: contract.v1.DashboardService.Render:input_type -> contract.v1.RenderRequest + 2, // 29: contract.v1.PluginManifest.GetManifest:output_type -> contract.v1.GetManifestResponse + 6, // 30: contract.v1.PluginManifest.HealthCheck:output_type -> contract.v1.HealthCheckResponse + 8, // 31: contract.v1.InvocationBroker.Invoke:output_type -> contract.v1.InvokeResponse + 10, // 32: contract.v1.InvocationBroker.ListPlugins:output_type -> contract.v1.ListPluginsResponse + 13, // 33: contract.v1.EchoService.Echo:output_type -> contract.v1.EchoResponse + 15, // 34: contract.v1.TransformService.Transform:output_type -> contract.v1.TransformResponse + 17, // 35: contract.v1.StreamingDemo.UnaryPing:output_type -> contract.v1.PingResponse + 19, // 36: contract.v1.StreamingDemo.StreamEvents:output_type -> contract.v1.StreamEvent + 23, // 37: contract.v1.RecorderService.Save:output_type -> contract.v1.SaveResponse + 25, // 38: contract.v1.RecorderService.List:output_type -> contract.v1.ListResponse + 27, // 39: contract.v1.RecorderService.ReadBlob:output_type -> contract.v1.ReadBlobResponse + 30, // 40: contract.v1.RecorderService.Stats:output_type -> contract.v1.StatsResponse + 21, // 41: contract.v1.UsageService.ParseBody:output_type -> contract.v1.UsageInfo + 21, // 42: contract.v1.UsageService.ParseStream:output_type -> contract.v1.UsageInfo + 34, // 43: contract.v1.ProxyService.Handle:output_type -> contract.v1.HandleResponse + 36, // 44: contract.v1.DashboardService.Render:output_type -> contract.v1.RenderResponse + 29, // [29:45] is the sub-list for method output_type + 13, // [13:29] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name +} + +func init() { file_contract_v1_contract_proto_init() } +func file_contract_v1_contract_proto_init() { + if File_contract_v1_contract_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_contract_v1_contract_proto_rawDesc), len(file_contract_v1_contract_proto_rawDesc)), + NumEnums: 1, + NumMessages: 40, + NumExtensions: 0, + NumServices: 9, + }, + GoTypes: file_contract_v1_contract_proto_goTypes, + DependencyIndexes: file_contract_v1_contract_proto_depIdxs, + EnumInfos: file_contract_v1_contract_proto_enumTypes, + MessageInfos: file_contract_v1_contract_proto_msgTypes, + }.Build() + File_contract_v1_contract_proto = out.File + file_contract_v1_contract_proto_goTypes = nil + file_contract_v1_contract_proto_depIdxs = nil +} diff --git a/src/go/proto/contract/v1/contract_grpc.pb.go b/src/go/proto/contract/v1/contract_grpc.pb.go new file mode 100644 index 0000000..85098b1 --- /dev/null +++ b/src/go/proto/contract/v1/contract_grpc.pb.go @@ -0,0 +1,1243 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.3 +// source: contract/v1/contract.proto + +package contractv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + PluginManifest_GetManifest_FullMethodName = "/contract.v1.PluginManifest/GetManifest" + PluginManifest_HealthCheck_FullMethodName = "/contract.v1.PluginManifest/HealthCheck" +) + +// PluginManifestClient is the client API for PluginManifest service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ─── 通用服务:每个插件都必须实现 ─── +type PluginManifestClient interface { + GetManifest(ctx context.Context, in *GetManifestRequest, opts ...grpc.CallOption) (*GetManifestResponse, error) + HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) +} + +type pluginManifestClient struct { + cc grpc.ClientConnInterface +} + +func NewPluginManifestClient(cc grpc.ClientConnInterface) PluginManifestClient { + return &pluginManifestClient{cc} +} + +func (c *pluginManifestClient) GetManifest(ctx context.Context, in *GetManifestRequest, opts ...grpc.CallOption) (*GetManifestResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetManifestResponse) + err := c.cc.Invoke(ctx, PluginManifest_GetManifest_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pluginManifestClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthCheckResponse) + err := c.cc.Invoke(ctx, PluginManifest_HealthCheck_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PluginManifestServer is the server API for PluginManifest service. +// All implementations must embed UnimplementedPluginManifestServer +// for forward compatibility. +// +// ─── 通用服务:每个插件都必须实现 ─── +type PluginManifestServer interface { + GetManifest(context.Context, *GetManifestRequest) (*GetManifestResponse, error) + HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) + mustEmbedUnimplementedPluginManifestServer() +} + +// UnimplementedPluginManifestServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPluginManifestServer struct{} + +func (UnimplementedPluginManifestServer) GetManifest(context.Context, *GetManifestRequest) (*GetManifestResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetManifest not implemented") +} +func (UnimplementedPluginManifestServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { + return nil, status.Error(codes.Unimplemented, "method HealthCheck not implemented") +} +func (UnimplementedPluginManifestServer) mustEmbedUnimplementedPluginManifestServer() {} +func (UnimplementedPluginManifestServer) testEmbeddedByValue() {} + +// UnsafePluginManifestServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PluginManifestServer will +// result in compilation errors. +type UnsafePluginManifestServer interface { + mustEmbedUnimplementedPluginManifestServer() +} + +func RegisterPluginManifestServer(s grpc.ServiceRegistrar, srv PluginManifestServer) { + // If the following call panics, it indicates UnimplementedPluginManifestServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PluginManifest_ServiceDesc, srv) +} + +func _PluginManifest_GetManifest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetManifestRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginManifestServer).GetManifest(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PluginManifest_GetManifest_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginManifestServer).GetManifest(ctx, req.(*GetManifestRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PluginManifest_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthCheckRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginManifestServer).HealthCheck(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PluginManifest_HealthCheck_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginManifestServer).HealthCheck(ctx, req.(*HealthCheckRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// PluginManifest_ServiceDesc is the grpc.ServiceDesc for PluginManifest service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var PluginManifest_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.PluginManifest", + HandlerType: (*PluginManifestServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetManifest", + Handler: _PluginManifest_GetManifest_Handler, + }, + { + MethodName: "HealthCheck", + Handler: _PluginManifest_HealthCheck_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + InvocationBroker_Invoke_FullMethodName = "/contract.v1.InvocationBroker/Invoke" + InvocationBroker_ListPlugins_FullMethodName = "/contract.v1.InvocationBroker/ListPlugins" +) + +// InvocationBrokerClient is the client API for InvocationBroker service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ─── 调用代理:由内核托管,被插件调用 ─── +type InvocationBrokerClient interface { + Invoke(ctx context.Context, in *InvokeRequest, opts ...grpc.CallOption) (*InvokeResponse, error) + ListPlugins(ctx context.Context, in *ListPluginsRequest, opts ...grpc.CallOption) (*ListPluginsResponse, error) +} + +type invocationBrokerClient struct { + cc grpc.ClientConnInterface +} + +func NewInvocationBrokerClient(cc grpc.ClientConnInterface) InvocationBrokerClient { + return &invocationBrokerClient{cc} +} + +func (c *invocationBrokerClient) Invoke(ctx context.Context, in *InvokeRequest, opts ...grpc.CallOption) (*InvokeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InvokeResponse) + err := c.cc.Invoke(ctx, InvocationBroker_Invoke_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *invocationBrokerClient) ListPlugins(ctx context.Context, in *ListPluginsRequest, opts ...grpc.CallOption) (*ListPluginsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListPluginsResponse) + err := c.cc.Invoke(ctx, InvocationBroker_ListPlugins_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// InvocationBrokerServer is the server API for InvocationBroker service. +// All implementations must embed UnimplementedInvocationBrokerServer +// for forward compatibility. +// +// ─── 调用代理:由内核托管,被插件调用 ─── +type InvocationBrokerServer interface { + Invoke(context.Context, *InvokeRequest) (*InvokeResponse, error) + ListPlugins(context.Context, *ListPluginsRequest) (*ListPluginsResponse, error) + mustEmbedUnimplementedInvocationBrokerServer() +} + +// UnimplementedInvocationBrokerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedInvocationBrokerServer struct{} + +func (UnimplementedInvocationBrokerServer) Invoke(context.Context, *InvokeRequest) (*InvokeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Invoke not implemented") +} +func (UnimplementedInvocationBrokerServer) ListPlugins(context.Context, *ListPluginsRequest) (*ListPluginsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListPlugins not implemented") +} +func (UnimplementedInvocationBrokerServer) mustEmbedUnimplementedInvocationBrokerServer() {} +func (UnimplementedInvocationBrokerServer) testEmbeddedByValue() {} + +// UnsafeInvocationBrokerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to InvocationBrokerServer will +// result in compilation errors. +type UnsafeInvocationBrokerServer interface { + mustEmbedUnimplementedInvocationBrokerServer() +} + +func RegisterInvocationBrokerServer(s grpc.ServiceRegistrar, srv InvocationBrokerServer) { + // If the following call panics, it indicates UnimplementedInvocationBrokerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&InvocationBroker_ServiceDesc, srv) +} + +func _InvocationBroker_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InvokeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InvocationBrokerServer).Invoke(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InvocationBroker_Invoke_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InvocationBrokerServer).Invoke(ctx, req.(*InvokeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _InvocationBroker_ListPlugins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListPluginsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InvocationBrokerServer).ListPlugins(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InvocationBroker_ListPlugins_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InvocationBrokerServer).ListPlugins(ctx, req.(*ListPluginsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// InvocationBroker_ServiceDesc is the grpc.ServiceDesc for InvocationBroker service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var InvocationBroker_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.InvocationBroker", + HandlerType: (*InvocationBrokerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Invoke", + Handler: _InvocationBroker_Invoke_Handler, + }, + { + MethodName: "ListPlugins", + Handler: _InvocationBroker_ListPlugins_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + EchoService_Echo_FullMethodName = "/contract.v1.EchoService/Echo" +) + +// EchoServiceClient is the client API for EchoService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// 基本插件 +type EchoServiceClient interface { + Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoResponse, error) +} + +type echoServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewEchoServiceClient(cc grpc.ClientConnInterface) EchoServiceClient { + return &echoServiceClient{cc} +} + +func (c *echoServiceClient) Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EchoResponse) + err := c.cc.Invoke(ctx, EchoService_Echo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// EchoServiceServer is the server API for EchoService service. +// All implementations must embed UnimplementedEchoServiceServer +// for forward compatibility. +// +// 基本插件 +type EchoServiceServer interface { + Echo(context.Context, *EchoRequest) (*EchoResponse, error) + mustEmbedUnimplementedEchoServiceServer() +} + +// UnimplementedEchoServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedEchoServiceServer struct{} + +func (UnimplementedEchoServiceServer) Echo(context.Context, *EchoRequest) (*EchoResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Echo not implemented") +} +func (UnimplementedEchoServiceServer) mustEmbedUnimplementedEchoServiceServer() {} +func (UnimplementedEchoServiceServer) testEmbeddedByValue() {} + +// UnsafeEchoServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to EchoServiceServer will +// result in compilation errors. +type UnsafeEchoServiceServer interface { + mustEmbedUnimplementedEchoServiceServer() +} + +func RegisterEchoServiceServer(s grpc.ServiceRegistrar, srv EchoServiceServer) { + // If the following call panics, it indicates UnimplementedEchoServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&EchoService_ServiceDesc, srv) +} + +func _EchoService_Echo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EchoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EchoServiceServer).Echo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EchoService_Echo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EchoServiceServer).Echo(ctx, req.(*EchoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// EchoService_ServiceDesc is the grpc.ServiceDesc for EchoService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var EchoService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.EchoService", + HandlerType: (*EchoServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Echo", + Handler: _EchoService_Echo_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + TransformService_Transform_FullMethodName = "/contract.v1.TransformService/Transform" +) + +// TransformServiceClient is the client API for TransformService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// 依赖基本(内部调 Echo) +type TransformServiceClient interface { + Transform(ctx context.Context, in *TransformRequest, opts ...grpc.CallOption) (*TransformResponse, error) +} + +type transformServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTransformServiceClient(cc grpc.ClientConnInterface) TransformServiceClient { + return &transformServiceClient{cc} +} + +func (c *transformServiceClient) Transform(ctx context.Context, in *TransformRequest, opts ...grpc.CallOption) (*TransformResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TransformResponse) + err := c.cc.Invoke(ctx, TransformService_Transform_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TransformServiceServer is the server API for TransformService service. +// All implementations must embed UnimplementedTransformServiceServer +// for forward compatibility. +// +// 依赖基本(内部调 Echo) +type TransformServiceServer interface { + Transform(context.Context, *TransformRequest) (*TransformResponse, error) + mustEmbedUnimplementedTransformServiceServer() +} + +// UnimplementedTransformServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTransformServiceServer struct{} + +func (UnimplementedTransformServiceServer) Transform(context.Context, *TransformRequest) (*TransformResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Transform not implemented") +} +func (UnimplementedTransformServiceServer) mustEmbedUnimplementedTransformServiceServer() {} +func (UnimplementedTransformServiceServer) testEmbeddedByValue() {} + +// UnsafeTransformServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TransformServiceServer will +// result in compilation errors. +type UnsafeTransformServiceServer interface { + mustEmbedUnimplementedTransformServiceServer() +} + +func RegisterTransformServiceServer(s grpc.ServiceRegistrar, srv TransformServiceServer) { + // If the following call panics, it indicates UnimplementedTransformServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TransformService_ServiceDesc, srv) +} + +func _TransformService_Transform_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TransformRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransformServiceServer).Transform(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransformService_Transform_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransformServiceServer).Transform(ctx, req.(*TransformRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// TransformService_ServiceDesc is the grpc.ServiceDesc for TransformService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TransformService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.TransformService", + HandlerType: (*TransformServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Transform", + Handler: _TransformService_Transform_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + StreamingDemo_UnaryPing_FullMethodName = "/contract.v1.StreamingDemo/UnaryPing" + StreamingDemo_StreamEvents_FullMethodName = "/contract.v1.StreamingDemo/StreamEvents" +) + +// StreamingDemoClient is the client API for StreamingDemo service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// 复杂(依赖 base+mid,含 server-streaming,验证 SSE-over-gRPC) +type StreamingDemoClient interface { + UnaryPing(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) + StreamEvents(ctx context.Context, in *StreamRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamEvent], error) +} + +type streamingDemoClient struct { + cc grpc.ClientConnInterface +} + +func NewStreamingDemoClient(cc grpc.ClientConnInterface) StreamingDemoClient { + return &streamingDemoClient{cc} +} + +func (c *streamingDemoClient) UnaryPing(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingResponse) + err := c.cc.Invoke(ctx, StreamingDemo_UnaryPing_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *streamingDemoClient) StreamEvents(ctx context.Context, in *StreamRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StreamingDemo_ServiceDesc.Streams[0], StreamingDemo_StreamEvents_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StreamRequest, StreamEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StreamingDemo_StreamEventsClient = grpc.ServerStreamingClient[StreamEvent] + +// StreamingDemoServer is the server API for StreamingDemo service. +// All implementations must embed UnimplementedStreamingDemoServer +// for forward compatibility. +// +// 复杂(依赖 base+mid,含 server-streaming,验证 SSE-over-gRPC) +type StreamingDemoServer interface { + UnaryPing(context.Context, *PingRequest) (*PingResponse, error) + StreamEvents(*StreamRequest, grpc.ServerStreamingServer[StreamEvent]) error + mustEmbedUnimplementedStreamingDemoServer() +} + +// UnimplementedStreamingDemoServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStreamingDemoServer struct{} + +func (UnimplementedStreamingDemoServer) UnaryPing(context.Context, *PingRequest) (*PingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UnaryPing not implemented") +} +func (UnimplementedStreamingDemoServer) StreamEvents(*StreamRequest, grpc.ServerStreamingServer[StreamEvent]) error { + return status.Error(codes.Unimplemented, "method StreamEvents not implemented") +} +func (UnimplementedStreamingDemoServer) mustEmbedUnimplementedStreamingDemoServer() {} +func (UnimplementedStreamingDemoServer) testEmbeddedByValue() {} + +// UnsafeStreamingDemoServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StreamingDemoServer will +// result in compilation errors. +type UnsafeStreamingDemoServer interface { + mustEmbedUnimplementedStreamingDemoServer() +} + +func RegisterStreamingDemoServer(s grpc.ServiceRegistrar, srv StreamingDemoServer) { + // If the following call panics, it indicates UnimplementedStreamingDemoServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StreamingDemo_ServiceDesc, srv) +} + +func _StreamingDemo_UnaryPing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StreamingDemoServer).UnaryPing(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StreamingDemo_UnaryPing_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StreamingDemoServer).UnaryPing(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _StreamingDemo_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StreamRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StreamingDemoServer).StreamEvents(m, &grpc.GenericServerStream[StreamRequest, StreamEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StreamingDemo_StreamEventsServer = grpc.ServerStreamingServer[StreamEvent] + +// StreamingDemo_ServiceDesc is the grpc.ServiceDesc for StreamingDemo service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StreamingDemo_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.StreamingDemo", + HandlerType: (*StreamingDemoServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "UnaryPing", + Handler: _StreamingDemo_UnaryPing_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamEvents", + Handler: _StreamingDemo_StreamEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "contract/v1/contract.proto", +} + +const ( + RecorderService_Save_FullMethodName = "/contract.v1.RecorderService/Save" + RecorderService_List_FullMethodName = "/contract.v1.RecorderService/List" + RecorderService_ReadBlob_FullMethodName = "/contract.v1.RecorderService/ReadBlob" + RecorderService_Stats_FullMethodName = "/contract.v1.RecorderService/Stats" +) + +// RecorderServiceClient is the client API for RecorderService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ── RecorderService(BASE):留存 + 查询 ── +type RecorderServiceClient interface { + Save(ctx context.Context, in *SaveRequest, opts ...grpc.CallOption) (*SaveResponse, error) + List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) + ReadBlob(ctx context.Context, in *ReadBlobRequest, opts ...grpc.CallOption) (*ReadBlobResponse, error) + Stats(ctx context.Context, in *StatsRequest, opts ...grpc.CallOption) (*StatsResponse, error) +} + +type recorderServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewRecorderServiceClient(cc grpc.ClientConnInterface) RecorderServiceClient { + return &recorderServiceClient{cc} +} + +func (c *recorderServiceClient) Save(ctx context.Context, in *SaveRequest, opts ...grpc.CallOption) (*SaveResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SaveResponse) + err := c.cc.Invoke(ctx, RecorderService_Save_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *recorderServiceClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListResponse) + err := c.cc.Invoke(ctx, RecorderService_List_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *recorderServiceClient) ReadBlob(ctx context.Context, in *ReadBlobRequest, opts ...grpc.CallOption) (*ReadBlobResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReadBlobResponse) + err := c.cc.Invoke(ctx, RecorderService_ReadBlob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *recorderServiceClient) Stats(ctx context.Context, in *StatsRequest, opts ...grpc.CallOption) (*StatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StatsResponse) + err := c.cc.Invoke(ctx, RecorderService_Stats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RecorderServiceServer is the server API for RecorderService service. +// All implementations must embed UnimplementedRecorderServiceServer +// for forward compatibility. +// +// ── RecorderService(BASE):留存 + 查询 ── +type RecorderServiceServer interface { + Save(context.Context, *SaveRequest) (*SaveResponse, error) + List(context.Context, *ListRequest) (*ListResponse, error) + ReadBlob(context.Context, *ReadBlobRequest) (*ReadBlobResponse, error) + Stats(context.Context, *StatsRequest) (*StatsResponse, error) + mustEmbedUnimplementedRecorderServiceServer() +} + +// UnimplementedRecorderServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedRecorderServiceServer struct{} + +func (UnimplementedRecorderServiceServer) Save(context.Context, *SaveRequest) (*SaveResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Save not implemented") +} +func (UnimplementedRecorderServiceServer) List(context.Context, *ListRequest) (*ListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method List not implemented") +} +func (UnimplementedRecorderServiceServer) ReadBlob(context.Context, *ReadBlobRequest) (*ReadBlobResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReadBlob not implemented") +} +func (UnimplementedRecorderServiceServer) Stats(context.Context, *StatsRequest) (*StatsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Stats not implemented") +} +func (UnimplementedRecorderServiceServer) mustEmbedUnimplementedRecorderServiceServer() {} +func (UnimplementedRecorderServiceServer) testEmbeddedByValue() {} + +// UnsafeRecorderServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RecorderServiceServer will +// result in compilation errors. +type UnsafeRecorderServiceServer interface { + mustEmbedUnimplementedRecorderServiceServer() +} + +func RegisterRecorderServiceServer(s grpc.ServiceRegistrar, srv RecorderServiceServer) { + // If the following call panics, it indicates UnimplementedRecorderServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&RecorderService_ServiceDesc, srv) +} + +func _RecorderService_Save_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SaveRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RecorderServiceServer).Save(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RecorderService_Save_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RecorderServiceServer).Save(ctx, req.(*SaveRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RecorderService_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RecorderServiceServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RecorderService_List_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RecorderServiceServer).List(ctx, req.(*ListRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RecorderService_ReadBlob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadBlobRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RecorderServiceServer).ReadBlob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RecorderService_ReadBlob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RecorderServiceServer).ReadBlob(ctx, req.(*ReadBlobRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RecorderService_Stats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RecorderServiceServer).Stats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RecorderService_Stats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RecorderServiceServer).Stats(ctx, req.(*StatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RecorderService_ServiceDesc is the grpc.ServiceDesc for RecorderService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RecorderService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.RecorderService", + HandlerType: (*RecorderServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Save", + Handler: _RecorderService_Save_Handler, + }, + { + MethodName: "List", + Handler: _RecorderService_List_Handler, + }, + { + MethodName: "ReadBlob", + Handler: _RecorderService_ReadBlob_Handler, + }, + { + MethodName: "Stats", + Handler: _RecorderService_Stats_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + UsageService_ParseBody_FullMethodName = "/contract.v1.UsageService/ParseBody" + UsageService_ParseStream_FullMethodName = "/contract.v1.UsageService/ParseStream" +) + +// UsageServiceClient is the client API for UsageService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ── UsageService(BASE):用量解析(非流式整体 / 流式全文) ── +type UsageServiceClient interface { + ParseBody(ctx context.Context, in *ParseBodyRequest, opts ...grpc.CallOption) (*UsageInfo, error) + ParseStream(ctx context.Context, in *ParseStreamRequest, opts ...grpc.CallOption) (*UsageInfo, error) +} + +type usageServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewUsageServiceClient(cc grpc.ClientConnInterface) UsageServiceClient { + return &usageServiceClient{cc} +} + +func (c *usageServiceClient) ParseBody(ctx context.Context, in *ParseBodyRequest, opts ...grpc.CallOption) (*UsageInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UsageInfo) + err := c.cc.Invoke(ctx, UsageService_ParseBody_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *usageServiceClient) ParseStream(ctx context.Context, in *ParseStreamRequest, opts ...grpc.CallOption) (*UsageInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UsageInfo) + err := c.cc.Invoke(ctx, UsageService_ParseStream_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// UsageServiceServer is the server API for UsageService service. +// All implementations must embed UnimplementedUsageServiceServer +// for forward compatibility. +// +// ── UsageService(BASE):用量解析(非流式整体 / 流式全文) ── +type UsageServiceServer interface { + ParseBody(context.Context, *ParseBodyRequest) (*UsageInfo, error) + ParseStream(context.Context, *ParseStreamRequest) (*UsageInfo, error) + mustEmbedUnimplementedUsageServiceServer() +} + +// UnimplementedUsageServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedUsageServiceServer struct{} + +func (UnimplementedUsageServiceServer) ParseBody(context.Context, *ParseBodyRequest) (*UsageInfo, error) { + return nil, status.Error(codes.Unimplemented, "method ParseBody not implemented") +} +func (UnimplementedUsageServiceServer) ParseStream(context.Context, *ParseStreamRequest) (*UsageInfo, error) { + return nil, status.Error(codes.Unimplemented, "method ParseStream not implemented") +} +func (UnimplementedUsageServiceServer) mustEmbedUnimplementedUsageServiceServer() {} +func (UnimplementedUsageServiceServer) testEmbeddedByValue() {} + +// UnsafeUsageServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to UsageServiceServer will +// result in compilation errors. +type UnsafeUsageServiceServer interface { + mustEmbedUnimplementedUsageServiceServer() +} + +func RegisterUsageServiceServer(s grpc.ServiceRegistrar, srv UsageServiceServer) { + // If the following call panics, it indicates UnimplementedUsageServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&UsageService_ServiceDesc, srv) +} + +func _UsageService_ParseBody_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ParseBodyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UsageServiceServer).ParseBody(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UsageService_ParseBody_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UsageServiceServer).ParseBody(ctx, req.(*ParseBodyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UsageService_ParseStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ParseStreamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UsageServiceServer).ParseStream(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UsageService_ParseStream_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UsageServiceServer).ParseStream(ctx, req.(*ParseStreamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// UsageService_ServiceDesc is the grpc.ServiceDesc for UsageService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var UsageService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.UsageService", + HandlerType: (*UsageServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ParseBody", + Handler: _UsageService_ParseBody_Handler, + }, + { + MethodName: "ParseStream", + Handler: _UsageService_ParseStream_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + ProxyService_Handle_FullMethodName = "/contract.v1.ProxyService/Handle" +) + +// ProxyServiceClient is the client API for ProxyService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ── ProxyService(COMPLEX):内核把外部请求转发到此,由它转上游并触发留存 ── +type ProxyServiceClient interface { + Handle(ctx context.Context, in *HandleRequest, opts ...grpc.CallOption) (*HandleResponse, error) +} + +type proxyServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewProxyServiceClient(cc grpc.ClientConnInterface) ProxyServiceClient { + return &proxyServiceClient{cc} +} + +func (c *proxyServiceClient) Handle(ctx context.Context, in *HandleRequest, opts ...grpc.CallOption) (*HandleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HandleResponse) + err := c.cc.Invoke(ctx, ProxyService_Handle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ProxyServiceServer is the server API for ProxyService service. +// All implementations must embed UnimplementedProxyServiceServer +// for forward compatibility. +// +// ── ProxyService(COMPLEX):内核把外部请求转发到此,由它转上游并触发留存 ── +type ProxyServiceServer interface { + Handle(context.Context, *HandleRequest) (*HandleResponse, error) + mustEmbedUnimplementedProxyServiceServer() +} + +// UnimplementedProxyServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedProxyServiceServer struct{} + +func (UnimplementedProxyServiceServer) Handle(context.Context, *HandleRequest) (*HandleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Handle not implemented") +} +func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {} +func (UnimplementedProxyServiceServer) testEmbeddedByValue() {} + +// UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ProxyServiceServer will +// result in compilation errors. +type UnsafeProxyServiceServer interface { + mustEmbedUnimplementedProxyServiceServer() +} + +func RegisterProxyServiceServer(s grpc.ServiceRegistrar, srv ProxyServiceServer) { + // If the following call panics, it indicates UnimplementedProxyServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ProxyService_ServiceDesc, srv) +} + +func _ProxyService_Handle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HandleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProxyServiceServer).Handle(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProxyService_Handle_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProxyServiceServer).Handle(ctx, req.(*HandleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ProxyService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.ProxyService", + HandlerType: (*ProxyServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Handle", + Handler: _ProxyService_Handle_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} + +const ( + DashboardService_Render_FullMethodName = "/contract.v1.DashboardService/Render" +) + +// DashboardServiceClient is the client API for DashboardService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ── DashboardService(MID,依赖 recorder):只读管理界面 ── +type DashboardServiceClient interface { + Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) +} + +type dashboardServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDashboardServiceClient(cc grpc.ClientConnInterface) DashboardServiceClient { + return &dashboardServiceClient{cc} +} + +func (c *dashboardServiceClient) Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenderResponse) + err := c.cc.Invoke(ctx, DashboardService_Render_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DashboardServiceServer is the server API for DashboardService service. +// All implementations must embed UnimplementedDashboardServiceServer +// for forward compatibility. +// +// ── DashboardService(MID,依赖 recorder):只读管理界面 ── +type DashboardServiceServer interface { + Render(context.Context, *RenderRequest) (*RenderResponse, error) + mustEmbedUnimplementedDashboardServiceServer() +} + +// UnimplementedDashboardServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDashboardServiceServer struct{} + +func (UnimplementedDashboardServiceServer) Render(context.Context, *RenderRequest) (*RenderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Render not implemented") +} +func (UnimplementedDashboardServiceServer) mustEmbedUnimplementedDashboardServiceServer() {} +func (UnimplementedDashboardServiceServer) testEmbeddedByValue() {} + +// UnsafeDashboardServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DashboardServiceServer will +// result in compilation errors. +type UnsafeDashboardServiceServer interface { + mustEmbedUnimplementedDashboardServiceServer() +} + +func RegisterDashboardServiceServer(s grpc.ServiceRegistrar, srv DashboardServiceServer) { + // If the following call panics, it indicates UnimplementedDashboardServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DashboardService_ServiceDesc, srv) +} + +func _DashboardService_Render_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DashboardServiceServer).Render(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DashboardService_Render_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DashboardServiceServer).Render(ctx, req.(*RenderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DashboardService_ServiceDesc is the grpc.ServiceDesc for DashboardService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DashboardService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "contract.v1.DashboardService", + HandlerType: (*DashboardServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Render", + Handler: _DashboardService_Render_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "contract/v1/contract.proto", +} diff --git a/src/go/proxy_test.go b/src/go/proxy_test.go deleted file mode 100644 index fa5f136..0000000 --- a/src/go/proxy_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -// newTestProxy 用被测的 Director/ModifyResponse 组装一个反向代理,指向 mock 上游。 -// 这一段刻意复刻 main() 里的装配逻辑,保证测的是真实代码路径。 -func newTestProxy(t *testing.T, upstreamURL string, rec *Recorder) http.Handler { - t.Helper() - target, err := url.Parse(upstreamURL) - if err != nil { - t.Fatalf("parse upstream: %v", err) - } - var id atomic.Int64 - proxy := &httputil.ReverseProxy{ - Director: func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.Host = target.Host - }, - ModifyResponse: recordResponse(rec), - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - var reqBody []byte - if r.Body != nil { - reqBody, _ = io.ReadAll(r.Body) - r.Body = io.NopCloser(bytes.NewReader(reqBody)) - } - entry := &Entry{ - ID: id.Add(1), - Timestamp: start, - ClientIP: clientIP(r), - Method: r.Method, - Path: r.URL.Path, - ReqHeader: flattenHeader(r.Header), - ReqBody: reqBody, - } - r = r.WithContext(withEntry(r.Context(), entry)) - proxy.ServeHTTP(w, r) - }) -} - -// TestProxyForwardAndRecord 验证非流式全链路: -// client -> proxy -> mock上游 -> proxy(留存) -> client, -// 并检查:响应原样回传、原文落盘完整、SQLite 索引含正确 usage。 -func TestProxyForwardAndRecord(t *testing.T) { - // 1) mock 上游:模拟 OpenAI 非流式响应,带 usage - respBody := `{"id":"chatcmpl-1","model":"gpt-4o-mini","choices":[{"message":{"role":"assistant","content":"你好"}}],"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18}}` - var gotAuth, gotReqBody string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - b, _ := io.ReadAll(r.Body) - gotReqBody = string(b) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - io.WriteString(w, respBody) - })) - defer upstream.Close() - - // 2) recorder 落到临时目录 - dir := t.TempDir() - rec, err := NewRecorder(dir) - if err != nil { - t.Fatalf("NewRecorder: %v", err) - } - defer rec.Close() - - // 3) 起代理 - proxy := httptest.NewServer(newTestProxy(t, upstream.URL, rec)) - defer proxy.Close() - - // 4) 客户端发请求(自带 key,验证透传不丢) - reqPayload := `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}` - req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, - proxy.URL+"/v1/chat/completions", strings.NewReader(reqPayload)) - req.Header.Set("Authorization", "Bearer sk-test-123") - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("client do: %v", err) - } - gotResp, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - // --- 断言 A:响应原样回传给客户端 --- - if string(gotResp) != respBody { - t.Fatalf("响应未原样回传:\n want %q\n got %q", respBody, gotResp) - } - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - - // --- 断言 B:key 透传到上游、请求体完整 --- - if gotAuth != "Bearer sk-test-123" { - t.Fatalf("key 未透传到上游: got %q", gotAuth) - } - if gotReqBody != reqPayload { - t.Fatalf("请求体未完整转发:\n want %q\n got %q", reqPayload, gotReqBody) - } - - // ModifyResponse 是同步执行的(在响应回客户端前),此时记录已落盘。 - // 5) 校验 SQLite 索引 - db, err := sql.Open("sqlite", filepath.Join(dir, "usage.db")) - if err != nil { - t.Fatalf("open db: %v", err) - } - defer db.Close() - - var ( - apiFormat, model, endpoint, blobPath string - in, out, total, status int - isStream int - ) - row := db.QueryRow(`SELECT api_format, model, endpoint, input_tokens, output_tokens, - total_tokens, is_stream, status, blob_path FROM traffic LIMIT 1`) - if err := row.Scan(&apiFormat, &model, &endpoint, &in, &out, &total, &isStream, &status, &blobPath); err != nil { - t.Fatalf("scan index row: %v", err) - } - if apiFormat != "openai" { - t.Errorf("api_format = %q, want openai", apiFormat) - } - if model != "gpt-4o-mini" { - t.Errorf("model = %q, want gpt-4o-mini", model) - } - if endpoint != "/v1/chat/completions" { - t.Errorf("endpoint = %q", endpoint) - } - if in != 11 || out != 7 || total != 18 { - t.Errorf("usage = in:%d out:%d total:%d, want 11/7/18", in, out, total) - } - if isStream != 0 { - t.Errorf("is_stream = %d, want 0", isStream) - } - if status != 200 { - t.Errorf("status = %d, want 200", status) - } - - // --- 断言 C:原文落盘完整,请求/响应原文都在 --- - raw, err := os.ReadFile(filepath.Join(dir, blobPath)) - if err != nil { - t.Fatalf("read blob: %v", err) - } - var b blob - if err := json.Unmarshal(raw, &b); err != nil { - t.Fatalf("unmarshal blob: %v", err) - } - if b.ReqBody != reqPayload { - t.Errorf("blob 请求原文不完整:\n want %q\n got %q", reqPayload, b.ReqBody) - } - if b.RespBody != respBody { - t.Errorf("blob 响应原文不完整:\n want %q\n got %q", respBody, b.RespBody) - } - // 安全留存的关键:客户端的 key 出现在留存的请求头里(全量留存要求) - if got := b.ReqHeader["Authorization"]; got != "Bearer sk-test-123" { - t.Errorf("留存的请求头缺少 Authorization: got %q", got) - } - t.Logf("全链路通过: model=%s in=%d out=%d blob=%s", model, in, out, blobPath) -} - -// TestParseUsageClaude 单独验证 Claude 字段命名也能解析。 -func TestParseUsageClaude(t *testing.T) { - body := []byte(`{"model":"claude-3-5-sonnet","usage":{"input_tokens":25,"output_tokens":40}}`) - var e Entry - parseUsage(&e, body) - if e.APIFormat != "claude" { - t.Fatalf("api_format = %q, want claude", e.APIFormat) - } - if e.InputTokens != 25 || e.OutputTokens != 40 || e.TotalTokens != 65 { - t.Fatalf("claude usage = %d/%d/%d, want 25/40/65", e.InputTokens, e.OutputTokens, e.TotalTokens) - } -} diff --git a/src/go/query.go b/src/go/query.go deleted file mode 100644 index 2d25622..0000000 --- a/src/go/query.go +++ /dev/null @@ -1,162 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -// Record 是 traffic 索引表的一行(不含原文,原文在 blob 文件里,按需取)。 -type Record struct { - ID int64 `json:"id"` - TS int64 `json:"ts"` // unix 毫秒 - ClientIP string `json:"client_ip"` - APIFormat string `json:"api_format"` - Endpoint string `json:"endpoint"` - Model string `json:"model"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` - IsStream bool `json:"is_stream"` - DurationMs int64 `json:"duration_ms"` - Status int `json:"status"` - BlobPath string `json:"blob_path"` -} - -// ListOptions 列表查询过滤条件。空值表示不过滤。 -type ListOptions struct { - Limit int - Offset int - Model string // 精确匹配 - Format string // 精确匹配 - Status int // >0 时精确匹配 -} - -// List 按条件查询索引记录,按 id 倒序(最新在前);返回本页记录 + 满足条件的总数。 -func (r *Recorder) List(opts ListOptions) ([]Record, int, error) { - var where []string - var args []any - if opts.Model != "" { - where = append(where, "model = ?") - args = append(args, opts.Model) - } - if opts.Format != "" { - where = append(where, "api_format = ?") - args = append(args, opts.Format) - } - if opts.Status > 0 { - where = append(where, "status = ?") - args = append(args, opts.Status) - } - clause := "" - if len(where) > 0 { - clause = "WHERE " + strings.Join(where, " AND ") - } - - // 总数(用于前端分页显示) - var total int - if err := r.db.QueryRow("SELECT COUNT(*) FROM traffic "+clause, args...).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("统计总数: %w", err) - } - - limit := opts.Limit - if limit <= 0 || limit > 500 { - limit = 100 - } - q := fmt.Sprintf(`SELECT id, ts, client_ip, api_format, endpoint, model, - input_tokens, output_tokens, total_tokens, is_stream, duration_ms, status, blob_path - FROM traffic %s ORDER BY id DESC LIMIT ? OFFSET ?`, clause) - args = append(args, limit, opts.Offset) - - rows, err := r.db.Query(q, args...) - if err != nil { - return nil, 0, fmt.Errorf("查询列表: %w", err) - } - defer rows.Close() - - var out []Record - for rows.Next() { - var rec Record - var isStream int - if err := rows.Scan(&rec.ID, &rec.TS, &rec.ClientIP, &rec.APIFormat, &rec.Endpoint, - &rec.Model, &rec.InputTokens, &rec.OutputTokens, &rec.TotalTokens, - &isStream, &rec.DurationMs, &rec.Status, &rec.BlobPath); err != nil { - return nil, 0, fmt.Errorf("扫描行: %w", err) - } - rec.IsStream = isStream != 0 - out = append(out, rec) - } - return out, total, rows.Err() -} - -// ReadBlob 按记录 id 找到 blob_path 并读回完整原文。 -func (r *Recorder) ReadBlob(id int64) (*blob, error) { - var rel string - if err := r.db.QueryRow("SELECT blob_path FROM traffic WHERE id = ?", id).Scan(&rel); err != nil { - return nil, fmt.Errorf("查记录 %d: %w", id, err) - } - abs := filepath.Join(r.dataDir, rel) - data, err := os.ReadFile(abs) - if err != nil { - return nil, fmt.Errorf("读原文 %s: %w", rel, err) - } - var b blob - if err := json.Unmarshal(data, &b); err != nil { - return nil, fmt.Errorf("解析原文: %w", err) - } - return &b, nil -} - -// ModelStat 单个模型的聚合统计。 -type ModelStat struct { - Model string `json:"model"` - Count int `json:"count"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` -} - -// Stats 整体聚合:总请求数、成功/失败数、token 总量、按模型明细。 -type Stats struct { - TotalRequests int `json:"total_requests"` - SuccessCount int `json:"success_count"` - ErrorCount int `json:"error_count"` - TotalTokens int64 `json:"total_tokens"` - ByModel []ModelStat `json:"by_model"` -} - -// Stats 计算整体用量聚合。 -func (r *Recorder) Stats() (*Stats, error) { - s := &Stats{ByModel: []ModelStat{}} - row := r.db.QueryRow(`SELECT - COUNT(*), - COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN status >= 400 OR status = 0 THEN 1 ELSE 0 END), 0), - COALESCE(SUM(total_tokens), 0) - FROM traffic`) - if err := row.Scan(&s.TotalRequests, &s.SuccessCount, &s.ErrorCount, &s.TotalTokens); err != nil { - return nil, fmt.Errorf("汇总: %w", err) - } - - rows, err := r.db.Query(`SELECT - COALESCE(NULLIF(model, ''), '(未知)') AS m, - COUNT(*), - COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(total_tokens), 0) - FROM traffic GROUP BY m ORDER BY SUM(total_tokens) DESC, COUNT(*) DESC`) - if err != nil { - return nil, fmt.Errorf("按模型聚合: %w", err) - } - defer rows.Close() - for rows.Next() { - var ms ModelStat - if err := rows.Scan(&ms.Model, &ms.Count, &ms.InputTokens, &ms.OutputTokens, &ms.TotalTokens); err != nil { - return nil, fmt.Errorf("扫描模型统计: %w", err) - } - s.ByModel = append(s.ByModel, ms) - } - return s, rows.Err() -} diff --git a/src/go/recorder.go b/src/go/recorder.go deleted file mode 100644 index b2f7df5..0000000 --- a/src/go/recorder.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - _ "modernc.org/sqlite" -) - -// Recorder 负责全量留存:原文落盘 + SQLite 索引。 -type Recorder struct { - dataDir string - db *sql.DB - mu sync.Mutex // 串行化写库,sqlite 单写者 -} - -// blob 落盘的原文结构:请求 + 响应完整内容。 -type blob struct { - ID int64 `json:"id"` - Timestamp string `json:"timestamp"` - ClientIP string `json:"client_ip"` - Method string `json:"method"` - Path string `json:"path"` - ReqHeader map[string]string `json:"req_header"` - ReqBody string `json:"req_body"` - Status int `json:"status"` - RespHeader map[string]string `json:"resp_header"` - RespBody string `json:"resp_body"` - DurationMs int64 `json:"duration_ms"` -} - -// NewRecorder 创建数据目录、打开 sqlite、建表。 -func NewRecorder(dir string) (*Recorder, error) { - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("创建数据目录: %w", err) - } - dbPath := filepath.Join(dir, "usage.db") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("打开 sqlite: %w", err) - } - // WAL 提升并发读写表现,busy_timeout 避免写锁瞬时冲突直接报错 - for _, pragma := range []string{ - "PRAGMA journal_mode=WAL;", - "PRAGMA busy_timeout=5000;", - } { - if _, err := db.Exec(pragma); err != nil { - db.Close() - return nil, fmt.Errorf("设置 pragma %q: %w", pragma, err) - } - } - - schema := ` -CREATE TABLE IF NOT EXISTS traffic ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - client_ip TEXT, - api_format TEXT, - endpoint TEXT, - model TEXT, - input_tokens INTEGER, - output_tokens INTEGER, - total_tokens INTEGER, - is_stream INTEGER, - duration_ms INTEGER, - status INTEGER, - blob_path TEXT -); -CREATE INDEX IF NOT EXISTS idx_traffic_ts ON traffic(ts); -CREATE INDEX IF NOT EXISTS idx_traffic_model ON traffic(model);` - if _, err := db.Exec(schema); err != nil { - db.Close() - return nil, fmt.Errorf("建表: %w", err) - } - - return &Recorder{dataDir: dir, db: db}, nil -} - -// Save 把一条记录写入 SQLite 索引并将原文落盘。 -// -// 顺序很关键:先 INSERT(不带 id,由 SQLite AUTOINCREMENT 分配), -// 拿回 LastInsertId() 作为权威主键,再用它命名 blob 文件并回填 e.ID。 -// 这样 id 由 DB 全局唯一递增,跨进程重启不再冲突;blob 文件名也随之唯一。 -func (r *Recorder) Save(e *Entry) error { - // 1) 先写 SQLite 索引,blob_path 暂留空,拿到自增 id 后再回填。 - r.mu.Lock() - res, err := r.db.Exec( - `INSERT INTO traffic - (ts, client_ip, api_format, endpoint, model, - input_tokens, output_tokens, total_tokens, is_stream, - duration_ms, status, blob_path) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - e.Timestamp.UnixMilli(), e.ClientIP, e.APIFormat, e.Path, e.Model, - e.InputTokens, e.OutputTokens, e.TotalTokens, boolToInt(e.IsStream), - e.DurationMs, e.Status, "", - ) - if err != nil { - r.mu.Unlock() - return fmt.Errorf("写索引: %w", err) - } - id, err := res.LastInsertId() - if err != nil { - r.mu.Unlock() - return fmt.Errorf("取自增 id: %w", err) - } - e.ID = id // 回填权威主键(供调用方日志/后续引用) - - // 2) 原文落盘:按 日期/ID.json 组织,用 DB 分配的 id 命名。 - day := e.Timestamp.Format("2006-01-02") - blobDir := filepath.Join(r.dataDir, "blobs", day) - if err := os.MkdirAll(blobDir, 0o700); err != nil { - r.mu.Unlock() - return fmt.Errorf("创建 blob 目录: %w", err) - } - rel := filepath.Join("blobs", day, fmt.Sprintf("%d.json", id)) - abs := filepath.Join(r.dataDir, rel) - - b := blob{ - ID: id, - Timestamp: e.Timestamp.Format(time.RFC3339Nano), - ClientIP: e.ClientIP, - Method: e.Method, - Path: e.Path, - ReqHeader: e.ReqHeader, - ReqBody: string(e.ReqBody), - Status: e.Status, - RespHeader: e.RespHeader, - RespBody: string(e.RespBody), - DurationMs: e.DurationMs, - } - data, err := json.MarshalIndent(b, "", " ") - if err != nil { - r.mu.Unlock() - return fmt.Errorf("序列化原文: %w", err) - } - if err := os.WriteFile(abs, data, 0o600); err != nil { - r.mu.Unlock() - return fmt.Errorf("写原文: %w", err) - } - e.BlobPath = rel - - // 3) 回填 blob_path 到刚插入的行。 - _, err = r.db.Exec("UPDATE traffic SET blob_path = ? WHERE id = ?", rel, id) - r.mu.Unlock() - if err != nil { - return fmt.Errorf("回填 blob_path: %w", err) - } - return nil -} - -// Close 关闭数据库。 -func (r *Recorder) Close() error { - if r.db != nil { - return r.db.Close() - } - return nil -} - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/src/go/sse.go b/src/go/sse.go deleted file mode 100644 index e221177..0000000 --- a/src/go/sse.go +++ /dev/null @@ -1,185 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "io" - "strings" -) - -// streamRecorder 包在 resp.Body 外层:数据被客户端读取时同步流过, -// 一边原样转发(客户端实时收到每个 chunk),一边把全量原文拼回 + 解析 SSE usage。 -// 读到 EOF(流结束)时回调 onDone,由它落库。 -// -// 这是流式代理的标准做法:用一个 io.ReadCloser 包装上游 body, -// 不做 io.ReadAll,避免缓冲整条流导致客户端要等所有 chunk 到齐才收到第一个字。 -type streamRecorder struct { - src io.ReadCloser - raw bytes.Buffer // 全量原文(拼回所有 chunk) - parser *sseParser - onDone func(raw []byte, in, out int, model string) - done bool -} - -func newStreamRecorder(src io.ReadCloser, format string, onDone func(raw []byte, in, out int, model string)) *streamRecorder { - return &streamRecorder{ - src: src, - parser: newSSEParser(format), - onDone: onDone, - } -} - -func (s *streamRecorder) Read(p []byte) (int, error) { - n, err := s.src.Read(p) - if n > 0 { - // 原样留一份,并喂给 SSE 解析器累积 usage - s.raw.Write(p[:n]) - s.parser.feed(p[:n]) - } - if err == io.EOF && !s.done { - s.done = true - s.parser.flush() - in, out, model := s.parser.result() - s.onDone(s.raw.Bytes(), in, out, model) - } - return n, err -} - -func (s *streamRecorder) Close() error { - // 客户端提前断开等情况:若还没触发 onDone,这里兜底落库一次。 - if !s.done { - s.done = true - s.parser.flush() - in, out, model := s.parser.result() - s.onDone(s.raw.Bytes(), in, out, model) - } - return s.src.Close() -} - -// sseParser 按行累积 SSE 数据,跨 chunk 解析 OpenAI / Claude 两家流式 usage。 -// -// OpenAI 流式:usage 出现在末尾带 stream_options.include_usage 的 chunk 里, -// 形如 data: {...,"usage":{"prompt_tokens":..,"completion_tokens":..}}; -// 没开 include_usage 则 usage 落空(如实记 0)。 -// Claude 流式:message_start 事件给 input_tokens(usage.input_tokens), -// message_delta 事件累加 output_tokens(usage.output_tokens),需跨事件攒。 -type sseParser struct { - format string - buf bytes.Buffer // 未成行的残留字节 - - in int - out int - model string -} - -func newSSEParser(format string) *sseParser { - return &sseParser{format: format} -} - -// feed 接收任意切片的字节,按行切出完整的 SSE 行交给 parseLine。 -func (p *sseParser) feed(b []byte) { - p.buf.Write(b) - data := p.buf.Bytes() - for { - i := bytes.IndexByte(data, '\n') - if i < 0 { - break - } - line := data[:i] - p.parseLine(line) - data = data[i+1:] - } - // 保留未成行的尾部 - rest := make([]byte, len(data)) - copy(rest, data) - p.buf.Reset() - p.buf.Write(rest) -} - -// flush 处理流结束时缓冲里残留的最后一行(可能没有结尾换行)。 -func (p *sseParser) flush() { - if p.buf.Len() > 0 { - p.parseLine(p.buf.Bytes()) - p.buf.Reset() - } -} - -// parseLine 解析单行 SSE。只关心 "data:" 行里的 JSON。 -func (p *sseParser) parseLine(line []byte) { - s := strings.TrimRight(string(line), "\r") - s = strings.TrimSpace(s) - if !strings.HasPrefix(s, "data:") { - return - } - payload := strings.TrimSpace(strings.TrimPrefix(s, "data:")) - if payload == "" || payload == "[DONE]" { - return - } - - var m map[string]json.RawMessage - if err := json.Unmarshal([]byte(payload), &m); err != nil { - return // 非 JSON 行,跳过 - } - - // model 字段(两家都有,取到就记) - if raw, ok := m["model"]; ok { - var mod string - if json.Unmarshal(raw, &mod) == nil && mod != "" { - p.model = mod - } - } - - // usage 可能直接在顶层(OpenAI 末尾 chunk), - // 也可能在 message.usage 里(Claude message_start)。 - if raw, ok := m["usage"]; ok { - p.applyUsage(raw) - } - if raw, ok := m["message"]; ok { - var msg map[string]json.RawMessage - if json.Unmarshal(raw, &msg) == nil { - if u, ok := msg["usage"]; ok { - p.applyUsage(u) - } - if mraw, ok := msg["model"]; ok { - var mod string - if json.Unmarshal(mraw, &mod) == nil && mod != "" { - p.model = mod - } - } - } - } -} - -// applyUsage 从一段 usage JSON 里抽 token 数,兼容两家命名。 -// Claude 的 output_tokens 在 message_delta 里是累计值(取最大即可), -// input_tokens 在 message_start 给定后不再变;OpenAI 末尾一次性给全量。 -func (p *sseParser) applyUsage(raw json.RawMessage) { - var u struct { - // OpenAI - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - // Claude - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - } - if json.Unmarshal(raw, &u) != nil { - return - } - // 取较大值,避免后到的事件覆盖成 0(Claude 各事件字段不全)。 - if u.PromptTokens > p.in { - p.in = u.PromptTokens - } - if u.InputTokens > p.in { - p.in = u.InputTokens - } - if u.CompletionTokens > p.out { - p.out = u.CompletionTokens - } - if u.OutputTokens > p.out { - p.out = u.OutputTokens - } -} - -func (p *sseParser) result() (in, out int, model string) { - return p.in, p.out, p.model -} diff --git a/src/go/sse_test.go b/src/go/sse_test.go deleted file mode 100644 index 0302eb6..0000000 --- a/src/go/sse_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "io" - "strings" - "testing" -) - -// feedInChunks 把一段完整 SSE 文本按指定大小切片喂给 streamRecorder, -// 模拟真实流式逐 chunk 到达(且切片边界可能落在一行中间), -// 验证跨 chunk 的行重组与 usage 累积都正确。 -func drainInChunks(t *testing.T, full string, format string, chunk int) (raw []byte, in, out int, model string) { - t.Helper() - src := io.NopCloser(strings.NewReader(full)) - var gotIn, gotOut int - var gotModel string - var gotRaw []byte - sr := newStreamRecorder(src, format, func(b []byte, i, o int, m string) { - gotRaw = append([]byte(nil), b...) - gotIn, gotOut, gotModel = i, o, m - }) - // 像客户端那样按小 buffer 反复读,直到 EOF - buf := make([]byte, chunk) - for { - _, err := sr.Read(buf) - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("read: %v", err) - } - } - return gotRaw, gotIn, gotOut, gotModel -} - -// TestSSEOpenAI 验证 OpenAI 流式:usage 在末尾带 include_usage 的 chunk 里。 -func TestSSEOpenAI(t *testing.T) { - full := strings.Join([]string{ - `data: {"model":"gpt-5.5","choices":[{"delta":{"content":"O"}}]}`, - `data: {"model":"gpt-5.5","choices":[{"delta":{"content":"K"}}]}`, - `data: {"model":"gpt-5.5","choices":[],"usage":{"prompt_tokens":42,"completion_tokens":3,"total_tokens":45}}`, - `data: [DONE]`, - "", - }, "\n") - - // 用一个会落在行中间的小 chunk,逼真地考验跨 chunk 重组 - raw, in, out, model := drainInChunks(t, full, "openai", 7) - if in != 42 || out != 3 { - t.Errorf("openai usage = in:%d out:%d, want 42/3", in, out) - } - if model != "gpt-5.5" { - t.Errorf("model = %q, want gpt-5.5", model) - } - if string(raw) != full { - t.Errorf("原文未完整拼回:\n want %q\n got %q", full, raw) - } -} - -// TestSSEClaude 验证 Claude 流式:input_tokens 在 message_start, -// output_tokens 在 message_delta 累计(取最大值)。 -func TestSSEClaude(t *testing.T) { - full := strings.Join([]string{ - `event: message_start`, - `data: {"type":"message_start","message":{"model":"claude-3-5-sonnet","usage":{"input_tokens":58,"output_tokens":1}}}`, - `event: content_block_delta`, - `data: {"type":"content_block_delta","delta":{"text":"hi"}}`, - `event: message_delta`, - `data: {"type":"message_delta","usage":{"output_tokens":27}}`, - `event: message_stop`, - `data: {"type":"message_stop"}`, - "", - }, "\n") - - raw, in, out, model := drainInChunks(t, full, "claude", 13) - if in != 58 { - t.Errorf("claude input_tokens = %d, want 58", in) - } - if out != 27 { - t.Errorf("claude output_tokens = %d, want 27 (message_delta 累计)", out) - } - if model != "claude-3-5-sonnet" { - t.Errorf("model = %q, want claude-3-5-sonnet", model) - } - if string(raw) != full { - t.Errorf("原文未完整拼回") - } -} - -// TestSSENoUsage 验证未开 include_usage 时 usage 落空但不报错、原文仍完整。 -func TestSSENoUsage(t *testing.T) { - full := strings.Join([]string{ - `data: {"model":"gpt-5.5","choices":[{"delta":{"content":"hi"}}]}`, - `data: [DONE]`, - "", - }, "\n") - raw, in, out, _ := drainInChunks(t, full, "openai", 1) - if in != 0 || out != 0 { - t.Errorf("无 usage 时应为 0/0, got %d/%d", in, out) - } - if string(raw) != full { - t.Errorf("原文未完整拼回") - } -} diff --git a/src/go/usage.go b/src/go/usage.go deleted file mode 100644 index 2fc3e01..0000000 --- a/src/go/usage.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" -) - -// parseUsage 从非流式响应体里解析用量信息,结果写回 entry。 -// 兼容 OpenAI、Claude 两种字段命名;识别不出就标 unknown,不报错。 -func parseUsage(entry *Entry, body []byte) { - if len(body) == 0 { - entry.APIFormat = "unknown" - return - } - - var m map[string]json.RawMessage - if err := json.Unmarshal(body, &m); err != nil { - entry.APIFormat = "unknown" - return - } - - // model 字段(OpenAI 和 Claude 都叫 model) - if raw, ok := m["model"]; ok { - var s string - if json.Unmarshal(raw, &s) == nil { - entry.Model = s - } - } - - usageRaw, ok := m["usage"] - if !ok { - entry.APIFormat = "unknown" - return - } - - // usage 字段名两家不同,都尝试一遍 - var u struct { - // OpenAI - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - // Claude - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - } - if err := json.Unmarshal(usageRaw, &u); err != nil { - entry.APIFormat = "unknown" - return - } - - switch { - case u.PromptTokens > 0 || u.CompletionTokens > 0 || u.TotalTokens > 0: - // OpenAI 风格 - entry.APIFormat = "openai" - entry.InputTokens = u.PromptTokens - entry.OutputTokens = u.CompletionTokens - entry.TotalTokens = u.TotalTokens - if entry.TotalTokens == 0 { - entry.TotalTokens = u.PromptTokens + u.CompletionTokens - } - case u.InputTokens > 0 || u.OutputTokens > 0: - // Claude 风格 - entry.APIFormat = "claude" - entry.InputTokens = u.InputTokens - entry.OutputTokens = u.OutputTokens - entry.TotalTokens = u.InputTokens + u.OutputTokens - default: - entry.APIFormat = "unknown" - } -} - -// guessFormatByPath 在拿不到 usage 时,按路径粗略猜测 API 格式(仅作辅助标注)。 -func guessFormatByPath(path string) string { - switch { - case strings.Contains(path, "/v1/messages"): - return "claude" - case strings.Contains(path, "/v1/chat/completions"), strings.Contains(path, "/v1/completions"): - return "openai" - case strings.Contains(path, "/api/chat"), strings.Contains(path, "/api/generate"): - return "ollama" - default: - return "unknown" - } -} diff --git a/src/go/web.go b/src/go/web.go deleted file mode 100644 index d32e0ad..0000000 --- a/src/go/web.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "net/http" - "strconv" -) - -// startWebUI 在独立端口起一个只读管理界面,对外提供 dashboard + JSON API。 -// 单独端口避免和反代端口冲突(反代会把所有路径转发上游)。 -func startWebUI(addr string, rec *Recorder) { - mux := webMux(rec) - log.Printf("Web 管理界面启动: http://%s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { - log.Printf("[!] Web 管理界面退出: %v", err) - } -} - -// webMux 组装管理界面的路由,单独抽出便于测试。 -func webMux(rec *Recorder) http.Handler { - mux := http.NewServeMux() - - // 首页:dashboard HTML - mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(dashboardHTML)) - }) - - // 聚合统计 - mux.HandleFunc("GET /api/stats", func(w http.ResponseWriter, r *http.Request) { - s, err := rec.Stats() - if err != nil { - writeErr(w, err) - return - } - writeJSON(w, s) - }) - - // 记录列表(支持 model/format/status/limit/offset 过滤) - mux.HandleFunc("GET /api/records", func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - opts := ListOptions{ - Model: q.Get("model"), - Format: q.Get("format"), - Limit: atoiDefault(q.Get("limit"), 100), - Offset: atoiDefault(q.Get("offset"), 0), - Status: atoiDefault(q.Get("status"), 0), - } - recs, total, err := rec.List(opts) - if err != nil { - writeErr(w, err) - return - } - if recs == nil { - recs = []Record{} - } - writeJSON(w, map[string]any{"total": total, "records": recs}) - }) - - // 单条记录原文详情(含完整请求/响应) - mux.HandleFunc("GET /api/records/{id}", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - http.Error(w, "无效 id", http.StatusBadRequest) - return - } - b, err := rec.ReadBlob(id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - writeJSON(w, b) - }) - - return mux -} - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := json.NewEncoder(w).Encode(v); err != nil { - log.Printf("[!] 写 JSON 响应失败: %v", err) - } -} - -func writeErr(w http.ResponseWriter, err error) { - http.Error(w, err.Error(), http.StatusInternalServerError) -} - -func atoiDefault(s string, def int) int { - if s == "" { - return def - } - n, err := strconv.Atoi(s) - if err != nil { - return def - } - return n -} diff --git a/src/go/web_test.go b/src/go/web_test.go deleted file mode 100644 index 84c7ad3..0000000 --- a/src/go/web_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// seedRecords 往 recorder 里塞几条记录,供 web 接口测试使用。 -func seedRecords(t *testing.T, rec *Recorder) { - t.Helper() - now := time.Now() - seeds := []*Entry{ - {ID: 1, Timestamp: now, ClientIP: "127.0.0.1", Method: "POST", Path: "/v1/chat/completions", - ReqHeader: map[string]string{"Authorization": "Bearer sk-aaa"}, ReqBody: []byte(`{"model":"gpt-5.5"}`), - Status: 200, RespHeader: map[string]string{"Content-Type": "application/json"}, RespBody: []byte(`{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`), - APIFormat: "openai", Model: "gpt-5.5", InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, - {ID: 2, Timestamp: now, ClientIP: "127.0.0.1", Method: "POST", Path: "/v1/messages", - ReqHeader: map[string]string{}, ReqBody: []byte(`{"model":"claude-3-5-sonnet"}`), - Status: 200, RespHeader: map[string]string{}, RespBody: []byte(`{}`), - APIFormat: "claude", Model: "claude-3-5-sonnet", InputTokens: 100, OutputTokens: 200, TotalTokens: 300}, - {ID: 3, Timestamp: now, ClientIP: "10.0.0.2", Method: "POST", Path: "/v1/chat/completions", - ReqHeader: map[string]string{}, ReqBody: []byte(`{}`), - Status: 503, RespHeader: map[string]string{}, RespBody: []byte(``), - APIFormat: "unknown", Model: "", InputTokens: 0, OutputTokens: 0, TotalTokens: 0}, - } - for _, e := range seeds { - if err := rec.Save(e); err != nil { - t.Fatalf("seed save #%d: %v", e.ID, err) - } - } -} - -// TestWebStats 验证 /api/stats 聚合正确。 -func TestWebStats(t *testing.T) { - rec, err := NewRecorder(t.TempDir()) - if err != nil { - t.Fatalf("NewRecorder: %v", err) - } - defer rec.Close() - seedRecords(t, rec) - - srv := httptest.NewServer(webMux(rec)) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/api/stats") - if err != nil { - t.Fatalf("get stats: %v", err) - } - defer resp.Body.Close() - var s Stats - if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { - t.Fatalf("decode stats: %v", err) - } - if s.TotalRequests != 3 { - t.Errorf("total_requests = %d, want 3", s.TotalRequests) - } - if s.SuccessCount != 2 { - t.Errorf("success_count = %d, want 2", s.SuccessCount) - } - if s.ErrorCount != 1 { - t.Errorf("error_count = %d, want 1", s.ErrorCount) - } - if s.TotalTokens != 315 { - t.Errorf("total_tokens = %d, want 315", s.TotalTokens) - } - if len(s.ByModel) != 3 { - t.Errorf("by_model 种类 = %d, want 3", len(s.ByModel)) - } -} - -// TestWebRecordsFilter 验证 /api/records 的过滤与分页。 -func TestWebRecordsFilter(t *testing.T) { - rec, err := NewRecorder(t.TempDir()) - if err != nil { - t.Fatalf("NewRecorder: %v", err) - } - defer rec.Close() - seedRecords(t, rec) - - srv := httptest.NewServer(webMux(rec)) - defer srv.Close() - - // 无过滤:3 条 - if total := recordsTotal(t, srv.URL+"/api/records"); total != 3 { - t.Errorf("无过滤 total = %d, want 3", total) - } - // 按格式过滤:openai 只有 1 条 - if total := recordsTotal(t, srv.URL+"/api/records?format=openai"); total != 1 { - t.Errorf("format=openai total = %d, want 1", total) - } - // 按状态过滤:503 只有 1 条 - if total := recordsTotal(t, srv.URL+"/api/records?status=503"); total != 1 { - t.Errorf("status=503 total = %d, want 1", total) - } - // 按模型过滤 - if total := recordsTotal(t, srv.URL+"/api/records?model=gpt-5.5"); total != 1 { - t.Errorf("model=gpt-5.5 total = %d, want 1", total) - } -} - -func recordsTotal(t *testing.T, url string) int { - t.Helper() - resp, err := http.Get(url) - if err != nil { - t.Fatalf("get %s: %v", url, err) - } - defer resp.Body.Close() - var out struct { - Total int `json:"total"` - Records []Record `json:"records"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - t.Fatalf("decode records: %v", err) - } - return out.Total -} - -// TestWebRecordDetail 验证 /api/records/{id} 返回完整原文(含留存的请求头)。 -func TestWebRecordDetail(t *testing.T) { - rec, err := NewRecorder(t.TempDir()) - if err != nil { - t.Fatalf("NewRecorder: %v", err) - } - defer rec.Close() - seedRecords(t, rec) - - srv := httptest.NewServer(webMux(rec)) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/api/records/1") - if err != nil { - t.Fatalf("get detail: %v", err) - } - defer resp.Body.Close() - var b blob - if err := json.NewDecoder(resp.Body).Decode(&b); err != nil { - t.Fatalf("decode blob: %v", err) - } - if b.ID != 1 { - t.Errorf("id = %d, want 1", b.ID) - } - if !strings.Contains(b.ReqBody, "gpt-5.5") { - t.Errorf("req_body 缺内容: %q", b.ReqBody) - } - // 全量留存:请求头里的凭证原样可查 - if b.ReqHeader["Authorization"] != "Bearer sk-aaa" { - t.Errorf("留存请求头缺 Authorization: %q", b.ReqHeader["Authorization"]) - } - - // 不存在的 id 应 404 - resp2, _ := http.Get(srv.URL + "/api/records/999") - if resp2 != nil { - resp2.Body.Close() - if resp2.StatusCode != http.StatusNotFound { - t.Errorf("不存在 id 状态 = %d, want 404", resp2.StatusCode) - } - } -} - -// TestWebDashboardHTML 验证首页返回 HTML。 -func TestWebDashboardHTML(t *testing.T) { - rec, err := NewRecorder(t.TempDir()) - if err != nil { - t.Fatalf("NewRecorder: %v", err) - } - defer rec.Close() - - srv := httptest.NewServer(webMux(rec)) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/") - if err != nil { - t.Fatalf("get /: %v", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q, want text/html", ct) - } - if !strings.Contains(string(body), "AI 通讯留存") { - t.Errorf("首页缺标题") - } -} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..bc02e78 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,9 @@ +# tools — 本地工具目录(预留) + +存放与本项目相关、但不属于主源码模块的本地辅助工具(如一次性脚本、调试器、客户端样例、性能测试器等)。当前为空。 + +约定: + +- 放进来的工具应自带说明(脚本头注释或子目录 README),不要依赖本目录之外的隐式状态。 +- 若某工具成长为长期组成部分,考虑迁入 `src/` 作为正式包,或独立成模块。 +- 跨语言客户端样例可放在此处(如 `tools/py-client/`),契约从 `proto/` 生成,见 `proto/README.md`。