Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions contrib/maintenance-sidecar/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
*
!Dockerfile
!*.py
!*.js
!*.css
!*.html
!vendor-manifest.json
!vendor/
!vendor/**
5 changes: 5 additions & 0 deletions contrib/maintenance-sidecar/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
state/
secrets/
*.private.json
9 changes: 9 additions & 0 deletions contrib/maintenance-sidecar/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libproxychains4 ca-certificates && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir cryptography==44.0.3
WORKDIR /app
COPY *.py *.js *.css *.html vendor-manifest.json /app/
COPY vendor/ /app/vendor/
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 TZ=Asia/Shanghai
EXPOSE 8080
CMD ["python", "/app/server.py"]
30 changes: 30 additions & 0 deletions contrib/maintenance-sidecar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Optional maintenance sidecar reference

This is a source reference for an independently operated Loomy/Raccoon maintenance
worker and its dashboard. It is deliberately not enabled by the default Agent2API
image, Compose file, plugin registry or UI. No existing production configuration is
changed by checking out this directory.

See [the implementation report](../../docs/maintenance-sidecar.md) for implemented
behaviour, integration contracts, limitations and isolated tests. Installation
status is recorded in [the installation guide](../../docs/install.en.md).

The main binary and model forwarding code remain unchanged. Balance-cache
synchronisation still requires an opt-in usage bridge around the official provider
plugin; "unmodified host" does **not** mean "no integration needed". The bridge
source is provided for review, with no automatic plugin replacement script.

The standalone Dockerfile builds only this source. It does not contain account
data, proxy subscriptions, operator keys, provider binaries or bridge configuration.
Runtime database/key/auth/bridge-key mounts and a compatible official provider
binary are operator-supplied. `MAINTENANCE_GATEWAY_URL` selects the management URL;
other `MAINTENANCE_*` paths/version/target values are declared in `server.py`.
Default startup is paused and automatic operations are disabled.

Current validation targets Linux amd64 and the documented provider/database
contract. Treat this as a draft reference, not a cross-version drop-in installer.
Database writes for check-in and renewable credentials, encrypted secret callbacks,
capacity rejections and stale-balance handling remain explicit review points.

Tom Select 2.4.3 is vendored under its MIT license; see `vendor/` and
`vendor-manifest.json`. The bridge uses only the Go standard library.
3 changes: 3 additions & 0 deletions contrib/maintenance-sidecar/bridge/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module agent2api-maintenance-bridge

go 1.22
175 changes: 175 additions & 0 deletions contrib/maintenance-sidecar/bridge/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Local maintenance bridge. Passes all original plugin frames unchanged except
// usage.query for the two explicitly supported providers. No model interception.
package main

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)

type config struct {
URL string `json:"url"`
Token string `json:"token"`
Timeout int `json:"timeout_seconds"`
}
type frame struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type parameters struct {
Provider string `json:"provider"`
Account string `json:"account"`
}

var output sync.Mutex

func write(b []byte) {
output.Lock()
defer output.Unlock()
os.Stdout.Write(b)
if len(b) == 0 || b[len(b)-1] != '\n' {
os.Stdout.Write([]byte{'\n'})
}
}
func failure(id json.RawMessage, message string) {
b, _ := json.Marshal(map[string]any{"id": id, "error": map[string]any{"message": message, "code": "maintenance_bridge"}})
write(b)
}
func query(f frame, c config) {
req, err := http.NewRequest("POST", c.URL, bytes.NewReader(f.Params))
if err != nil {
failure(f.ID, "维护桥请求配置错误")
return
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: time.Duration(c.Timeout) * time.Second, Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true}, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
failure(f.ID, "维护容器连接失败或超时;未回退直连")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
failure(f.ID, "维护结果读取失败")
return
}
var result map[string]json.RawMessage
if json.Unmarshal(body, &result) != nil {
failure(f.ID, "维护容器返回了无效结果")
return
}
if e, ok := result["error"]; ok {
b, _ := json.Marshal(map[string]any{"id": f.ID, "error": e})
write(b)
return
}
if resp.StatusCode != 200 {
failure(f.ID, fmt.Sprintf("维护容器返回 HTTP %d", resp.StatusCode))
return
}
u, ok := result["result"]
if !ok {
failure(f.ID, "维护结果缺少余额字段")
return
}
b, _ := json.Marshal(map[string]any{"id": f.ID, "result": u})
write(b)
}
func main() {
exe, err := os.Executable()
if err != nil {
os.Exit(1)
}
cfgPath := "/data/plugins/maintenance-bridge.private.json"
if x := os.Getenv("A2A_MAINTENANCE_BRIDGE_CONFIG"); x != "" {
cfgPath = x
}
var cfg config
b, err := os.ReadFile(cfgPath)
if err != nil || json.Unmarshal(b, &cfg) != nil || cfg.Token == "" || cfg.URL == "" {
fmt.Fprintln(os.Stderr, "maintenance bridge config unavailable")
os.Exit(1)
}
if cfg.Timeout < 10 || cfg.Timeout > 110 {
cfg.Timeout = 70
}
child := exec.Command(filepath.Join(filepath.Dir(exe), "agent2api-providers.official"))
child.Env = os.Environ()
child.Stderr = os.Stderr
stdin, err := child.StdinPipe()
if err != nil {
os.Exit(1)
}
stdout, err := child.StdoutPipe()
if err != nil {
os.Exit(1)
}
if child.Start() != nil {
os.Exit(1)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-stop
child.Process.Signal(syscall.SIGTERM)
time.Sleep(2 * time.Second)
child.Process.Kill()
os.Exit(0)
}()
go func() {
reader := bufio.NewReader(stdout)
for {
line, e := reader.ReadBytes('\n')
if len(line) > 0 {
write(line)
}
if e != nil {
break
}
}
child.Wait()
os.Exit(0)
}()
reader := bufio.NewReader(os.Stdin)
semaphore := make(chan struct{}, 32)
for {
line, e := reader.ReadBytes('\n')
if len(line) > 0 {
var f frame
var p parameters
intercept := json.Unmarshal(line, &f) == nil && f.Method == "usage.query" && json.Unmarshal(f.Params, &p) == nil && (p.Provider == "raccoon" || p.Provider == "loomy") && p.Account != ""
if intercept {
select {
case semaphore <- struct{}{}:
go func(f frame) { defer func() { <-semaphore }(); query(f, cfg) }(f)
default:
failure(f.ID, "维护余额并发已满,请由维护队列分批提交")
}
} else {
if _, err := stdin.Write(line); err != nil {
break
}
}
}
if e != nil {
break
}
}
stdin.Close()
child.Process.Signal(syscall.SIGTERM)
child.Wait()
}
128 changes: 128 additions & 0 deletions contrib/maintenance-sidecar/bridge/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
)

func TestUsageBridgeAndUnchangedModelFrames(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The reference adapter uses Linux process signals and a fixture shell child")
}
requests := make(chan map[string]string, 4)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.Header.Get("Authorization") != "Bearer fixture-only" {
t.Error("Unexpected method or missing bridge authentication")
w.WriteHeader(403)
return
}
var params map[string]string
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
t.Error(err)
}
requests <- params
w.Header().Set("Content-Type", "application/json")
if params["account"] == "error" {
w.Write([]byte(`{"error":{"code":"upstream","message":"fixture rejected"}}`))
return
}
w.Write([]byte(`{"result":{"totalLeft":123}}`))
}))
defer upstream.Close()
dir := t.TempDir()
bridge := filepath.Join(dir, "bridge")
if data, err := exec.Command("go", "build", "-o", bridge, ".").CombinedOutput(); err != nil {
t.Fatalf("build: %v %s", err, data)
}
// The original plugin is replaced only inside this temporary fixture directory.
if err := os.WriteFile(filepath.Join(dir, "agent2api-providers.official"), []byte("#!/bin/sh\nexec cat\n"), 0700); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(dir, "fixture.json")
data, _ := json.Marshal(config{URL: upstream.URL, Token: "fixture-only", Timeout: 10})
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bridge)
cmd.Env = append(os.Environ(), "A2A_MAINTENANCE_BRIDGE_CONFIG="+configPath)
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { stdin.Close(); cmd.Process.Kill(); cmd.Wait() }()
scanner := bufio.NewScanner(stdout)
lines := make(chan string, 8)
go func() {
for scanner.Scan() {
lines <- scanner.Text()
}
close(lines)
}()
read := func() string {
select {
case line, ok := <-lines:
if !ok {
t.Fatal("Bridge exited unexpectedly")
}
return line
case <-time.After(15 * time.Second):
t.Fatal("Timed out waiting for fixture response")
return ""
}
}
// Model calls, unrelated providers and plugin callbacks are byte-preserved.
for _, frame := range []string{
`{"id":1,"method":"chat.completions","params":{"provider":"loomy","account":"fixture"}}`,
`{"id":2,"method":"usage.query","params":{"provider":"other","account":"fixture"}}`,
`{"id":3,"result":{"secret":"synthetic-callback"}}`,
} {
stdin.Write([]byte(frame + "\n"))
if got := read(); got != frame {
t.Fatalf("frame changed: %s", got)
}
}
for _, provider := range []string{"loomy", "raccoon"} {
frame := `{"id":4,"method":"usage.query","params":{"provider":"` + provider + `","account":"fixture"}}`
stdin.Write([]byte(frame + "\n"))
var response struct {
ID int
Result struct{ TotalLeft int }
Error any
}
if err := json.Unmarshal([]byte(read()), &response); err != nil {
t.Fatal(err)
}
if response.ID != 4 || response.Result.TotalLeft != 123 || response.Error != nil {
t.Fatalf("bad response: %+v", response)
}
select {
case params := <-requests:
if params["provider"] != provider {
t.Fatal("provider not forwarded")
}
case <-time.After(time.Second):
t.Fatal("bridge not called")
}
}
stdin.Write([]byte(`{"id":5,"method":"usage.query","params":{"provider":"loomy","account":"error"}}` + "\n"))
var failure struct {
ID int
Error struct{ Code, Message string }
Result any
}
if err := json.Unmarshal([]byte(read()), &failure); err != nil {
t.Fatal(err)
}
if failure.ID != 5 || failure.Error.Code != "upstream" || failure.Result != nil {
t.Fatalf("upstream error not preserved: %+v", failure)
}
}
Loading